diff --git a/README.md b/README.md index 2fca31369..8cb8cc155 100644 --- a/README.md +++ b/README.md @@ -259,5 +259,5 @@ Full per-command reference, every flag, every envelope field, and every exit cod - **[docs/workflows.md](docs/workflows.md)** — How to author a project-local workflow descriptor: `hive workflow new` (with `--template`), `hive init --new-workflow`, `skill:` versus `instruction:`, and per-stage permissions. The full public walkthrough lives at **[hivecli.sh/docs/custom-workflows](https://hivecli.sh/docs/custom-workflows/)**. - **[wiki/operating.md](wiki/operating.md)** — Day-2 operations: install matrix, XDG paths, autostart (systemd-user on Linux, launchd on macOS), enrolling existing projects, the mandatory `--dry-run` shakedown, bot setup, tuning concurrency, cost-runaway response, troubleshooting. Read this before running the daemon live and any time you operate Hive across more than one project. - **[docs/recipes.md](docs/recipes.md)** — Concrete end-to-end workflows, including the xbookmark dogfood replay (linked to the real PR and a committed transcript of the run). Read this when you want to see what a complete idea-to-PR run looks like before trying it yourself. -- **[docs/faq.md](docs/faq.md)** — Troubleshooting and design-rationale answers: why folders instead of a database, why per-stage subprocesses instead of a long-running orchestrator, why commit `.hive-state/` to an orphan branch, why project-level daemon enrollment, why no built-in web UI. Read this when you hit a surprise or want to know "why is it like this?". +- **[docs/faq.md](docs/faq.md)** — Troubleshooting and design-rationale answers: why folders instead of a database, why per-stage subprocesses instead of a long-running orchestrator, why commit `.hive-state/` to an orphan branch, why project-level daemon enrollment, why the web UI is local-first (with Docker/hivebox still supported). Read this when you hit a surprise or want to know "why is it like this?". - **[wiki/index.md](wiki/index.md)** — The catalog of the LLM-maintained engineering wiki under `wiki/`, which is the deepest source of reference material for every command, module, and stage. Read this when the user-facing docs above don't have the depth you need. diff --git a/docs/faq.md b/docs/faq.md index a0971ceb1..8312bb2cc 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -22,9 +22,9 @@ Hive state changes often and should not pollute the project's code history or tr The daemon service is installed as global user infrastructure so it survives login and reboot. Project enrollment stays explicit because the daemon can spend real agent time and move many tasks; `daemon.enabled: true` is the durable consent signal for a specific repository, and `--dry-run` lets you inspect dispatches before live mode. -### Why no built-in web UI? +### Why is the web UI local-first (and what about Docker)? -The core interface is the filesystem and CLI. A web UI would add another state surface before the file protocol is finished. +Hive ships a first-class **local** web install: `hive setup` provisions the Rails bundle, the daemon service, and the web service, then serves the UI at `http://127.0.0.1:4567` with no GitHub login required on loopback. The Docker/hivebox image remains supported (it is the path that keeps the GitHub device-flow / owner gate). The gem itself stays web-less (ADR-037) — local mode obtains the matching Rails bundle separately, pinned to the installed gem version. ### Why more than one agent? diff --git a/docs/getting-started.md b/docs/getting-started.md index 104028df2..582bfc236 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -21,6 +21,32 @@ hive --version hive daemon install ``` +### Local web UI (optional) + +For a non-Docker machine, one command provisions and validates the full local +surface — CLI deps, the Rails web bundle, the daemon service, and the web +service — and serves the web UI at `http://127.0.0.1:4567`: + +```bash +hive setup +``` + +On the loopback bind (`127.0.0.1`) the web UI needs no GitHub login by +default (`web.auth_mode: loopback`). The web service is managed separately +from the daemon: + +```bash +hive web install # write/enable the per-user systemd/launchd unit +hive web start # run the web server in the foreground/background +hive web status # running/pid/uptime + service install state +hive web status --json +``` + +`hive setup` also enrolls the current repo in the daemon, so a task created +in the CLI/TUI appears in the web UI and dispatches automatically (and vice +versa). External agent CLIs (`claude` / `codex` / `gh`) are diagnosed, never +installed or authenticated for you. + ## Step 2 - Attach Hive To A Project ```bash diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist new file mode 100644 index 000000000..d850b229e --- /dev/null +++ b/examples/launchd/hive-web.plist @@ -0,0 +1,86 @@ + + + + + + Label + local.hive-web + + + ProgramArguments + + /bin/sh + -c + [ -x "$0" ] || exit 0; exec "$0" "$@" + /Users/YOU/.local/bin/hive + web + --bind + 127.0.0.1 + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + ThrottleInterval + 30 + + StandardOutPath + /Users/YOU/Library/Logs/hive-web.out.log + StandardErrorPath + /Users/YOU/Library/Logs/hive-web.err.log + + EnvironmentVariables + + PATH + /Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + HIVEBOX_WEB_APP_DIR + /Users/YOU/.local/share/hive/web + + + diff --git a/examples/systemd/hive-web.service b/examples/systemd/hive-web.service new file mode 100644 index 000000000..573cf1805 --- /dev/null +++ b/examples/systemd/hive-web.service @@ -0,0 +1,64 @@ +# Sample systemd-user unit for `hive web` (Linux) — the local managed web +# service, kept SEPARATE from `hive-daemon` (R7). +# +# This file is installer-managed: `hive web install` rewrites ExecStart=, +# Environment=PATH=, and Environment=HIVEBOX_WEB_APP_DIR= to match the +# resolved binary + Ruby manager + Rails app dir detected on the host, then +# enables + starts the unit. You normally do not edit this by hand — re-run +# `hive web install` instead. +# +# BEFORE INSTALLING BY HAND: edit ExecStart= to match where YOUR `hive` +# binary lives, and HIVEBOX_WEB_APP_DIR to the directory holding the Rails +# app's config/application.rb. `which hive` shows the binary. +# +# Install: +# mkdir -p ~/.config/systemd/user +# cp examples/systemd/hive-web.service ~/.config/systemd/user/ +# $EDITOR ~/.config/systemd/user/hive-web.service # confirm ExecStart= +# systemctl --user daemon-reload +# systemctl --user enable --now hive-web +# +# Verify it actually started: +# systemctl --user status hive-web +# journalctl --user -u hive-web -n 50 +# +# View logs: +# journalctl --user -u hive-web -f +# +# Stop / restart: +# systemctl --user stop hive-web +# systemctl --user restart hive-web +# +# To survive logout (run when no user session is active): +# sudo loginctl enable-linger $USER +# +# This unit runs `hive web` in the foreground (no subcommand = foreground); +# systemd is the supervisor and Restart=on-failure brings the web tier back +# if it crashes. The web tier binds 127.0.0.1 by default and, in the local +# `web.auth_mode: loopback` policy, needs no GitHub login there. + +[Unit] +Description=Hive web UI (local mode) +After=network-online.target +Wants=network-online.target +# Hard cap on the auto-restart loop, mirroring hive-daemon.service. +StartLimitBurst=3 +StartLimitIntervalSec=300 + +[Service] +Type=simple +# systemd user services do NOT inherit your interactive shell's PATH, so +# PATH below covers (a) incidental shell-outs the web tier performs and +# (b) the gem's bin/hive wrapper whose `#!/usr/bin/env ruby` shebang needs +# to find a Ruby with the gem's dependencies. `hive web install` detects +# mise/rbenv/asdf and prepends the matching shim directory automatically. +# HIVEBOX_WEB_APP_DIR points `hive web` at the Rails app so the managed +# service survives login/reboot without relying on the caller's cwd. +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +Environment=HIVEBOX_WEB_APP_DIR=%h/.local/share/hive/web +ExecStart=%h/.local/bin/hive web --bind 127.0.0.1 +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/install.md b/install.md index 8ad54e54d..dcc807f3f 100644 --- a/install.md +++ b/install.md @@ -136,6 +136,30 @@ If the current directory is a git project and the user wants Hive enabled here, During `hive init`, keep the user's prompt choices. The daemon prompt is per-project enrollment (`daemon.enabled`) only; the service autostart has already been installed globally. If init is non-interactive, Hive uses recommended defaults and enrolls the project. `hive doctor` runs AFTER `hive init` because it requires an initialized project root. +## Local Web UI (Optional) + +For a non-Docker machine that wants the web UI, offer (do not force) the +local surface. One command provisions the web bundle, daemon service, and web +service and serves the UI at `http://127.0.0.1:4567` with no GitHub login +required on loopback: + +```bash +"$hive_cmd" setup --json +``` + +`hive setup` diagnoses external agent CLIs (`gh` / `claude` / `codex`) and +reports the exact fix command for any missing or unauthenticated one — it +never installs or authenticates them. The managed web service is separate +from the daemon: + +```bash +"$hive_cmd" web install # write/enable the per-user systemd/launchd unit +"$hive_cmd" web status --json +``` + +Docker/hivebox remains supported and unchanged; it is the path that keeps the +GitHub device-flow / owner gate. + ## Optional Skills The Hive skills package is deferred to a v0.1.x follow-up (tracked in `wiki/gaps.md`). DO NOT RUN these commands until that package is published; treat the slugs below as the intended marketplace identifiers. If/when the package is published, offer the matching command: @@ -159,4 +183,5 @@ Report: - whether `hive init` was run - missing runtime dependencies from `hive doctor` - `qmd --version` output, or the reason QMD install/repair was skipped +- whether `hive setup` / `hive web install` (local web UI) was run - whether the optional skills package was installed or skipped diff --git a/lib/hive.rb b/lib/hive.rb index b22bfe0e3..de0cb93de 100644 --- a/lib/hive.rb +++ b/lib/hive.rb @@ -57,6 +57,10 @@ module Hive "hive-bot-stop" => 1, "hive-bot-reload" => 1, "hive-bot-install" => 1, + # Local managed web service status (`hive web status --json`). + "hive-web-status" => 1, + # One-shot local surface provisioning (`hive setup --json`). + "hive-setup" => 1, # File-backed dispatch request the bot writes for the daemon to # consume. One JSON file per pending request under the state-home # `dispatch_requests/` directory. See @@ -536,6 +540,23 @@ module Hive end end + # `hive web install` mirrors the daemon/bot install outcome split: drift is + # a recoverable USAGE error (re-run with --force) while a service-manager + # failure (systemctl / launchctl) is SOFTWARE. Separate classes so the + # top-level rescue maps each surface independently while still producing + # the stable 64 / 70 exit codes automation branches on. + class WebInstallDriftError < Error + def exit_code + ExitCodes::USAGE + end + end + + class WebInstallFailed < Error + def exit_code + ExitCodes::SOFTWARE + end + end + # Raised by `hive run` when the stage's terminal marker is :error. The # runner itself succeeded — the agent recorded a task-level failure. # Distinct from StageError (which signals a runner bug / git failure). diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index cd8090689..02078492f 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -257,6 +257,44 @@ module Hive ).call end + desc "setup", "Provision and validate the local Hive install (deps → qmd → web bundle → daemon → enrollment → web service)" + long_desc <<~DESC + One-shot setup for the first-class LOCAL (non-Docker) install. Runs, in + order, with each step idempotent and fail-fast-with-fix-command: + + 1. Backend prompt — persist which agents to provision globally + (Claude / Codex / Pi). Non-TTY (--non-interactive / piped stdin) + uses the defaults. + 2. Dependency check — ruby (3.4), git, tmux, gh (auth), claude, + codex, node/npm, sqlite3. External agent CLIs (gh/claude/codex) + are DIAGNOSE-ONLY: missing/unauth prints the exact fix command + and is reported blocked, never installed or authenticated. + 3. Bootstrap Hive-owned deps — qmd (managed npm install) and the + web bundle (obtain + bundle the Rails app at the installed gem + version). + 4. Daemon service — `hive daemon install --force` + a status/version + consistency probe. + 5. Project enrollment — `hive init .` (or `hive daemon enable` for an + already-registered project). + 6. Web service — `hive web install` + a deep health probe at + http://127.0.0.1:4567/health?deep=1. + + With --json, emits a single hive-setup.v1 document. A non-zero exit + means a hard dependency is missing or a step failed; blocked external + CLIs are warnings, not failures (the local web UI works without them). + + Exit codes: 0 success; 1 incomplete; 64 usage; 70 internal; 78 config. + DESC + option :non_interactive, type: :boolean, default: false, + desc: "skip prompts and use the default backends" + def setup + require "hive/commands/setup" + Hive::Commands::Setup.new( + json: options[:json], + non_interactive: options[:non_interactive] + ).call + end + desc "update", "Update hive via the install channel that installed it" long_desc <<~DESC Reads the install-channel marker written by the installer and delegates @@ -1332,18 +1370,46 @@ module Hive ).call end - desc "web", "Run the hivebox web UI" + desc "web [SUBCOMMAND]", "Run the hivebox web UI (foreground) or manage the local web service (install/start/stop/status)" + long_desc <<~DESC + With no subcommand, boots the Rails web UI in the foreground and + replaces this process with the Rails server (default bind + web.bind / web.port — 127.0.0.1:4567). The app must exist on disk: + run `hive setup` to provision it into ~/.local/share/hive/web, run + from the hivebox Docker image, or set HIVEBOX_WEB_APP_DIR. + + Subcommands manage a SEPARATE per-user service (systemd-user on + Linux, launchd on macOS), distinct from `hive daemon`: + + install [--force] (Re)write + enable the platform-native unit + (hive-web.service / local.hive-web.plist). + Without --force, refuses to overwrite a + pre-existing unit and exits 64 (USAGE); with + --force it backs up the prior file to + .bak- and restarts. + start Run the web server detached (pidfile at + /.web.pid). + stop SIGTERM the running web server (idempotent). + status [--json] running/pid/uptime + service install state. + --json emits hive-web-status.v1. + + Exit codes: 0 success; 1 web-not-running for `status` (scriptable + precondition probe); 64 USAGE for drift without --force / unknown + subcommand; 70 SOFTWARE for a service-manager failure; 75 TEMPFAIL + when `start` finds a live web server; 78 CONFIG for a refused + non-loopback bind under web.auth_mode=loopback. + DESC option :bind, type: :string, desc: "override web.bind" option :port, type: :numeric, desc: "override web.port" - def web - if options[:json] + option :force, type: :boolean, default: false, + desc: "for install: overwrite an existing unit (saves .bak)" + def web(subcommand = nil) + require "hive/commands/web" + if options[:json] && subcommand != "status" require "json" - message = "hive web has no JSON output (it runs a long-lived server). " \ - "Use 'hive status --json' for machine-readable task data." - # Mirror `hive tui`'s rejection: emit a structured error envelope (sans - # `schema`, since web has no registered hive-* schema) and raise - # InvalidTaskPath for the USAGE (64) exit code — parity with every - # other --json failure on this surface. + message = "hive web #{subcommand || '(foreground)'} has no JSON output " \ + "(the foreground server runs long-lived; only `hive web status --json` " \ + "emits an envelope)." puts JSON.generate( "ok" => false, "error_class" => "InvalidTaskPath", @@ -1354,8 +1420,13 @@ module Hive raise Hive::InvalidTaskPath, message end - require "hive/commands/web" - Hive::Commands::Web.new(bind: options[:bind], port: options[:port]).call + Hive::Commands::Web.new( + subcommand, + bind: options[:bind], + port: options[:port], + force: options[:force], + json: options[:json] + ).call end desc "tui", "Open the live, keystroke-driven dashboard for every active task" diff --git a/lib/hive/commands/daemon.rb b/lib/hive/commands/daemon.rb index 6c91610b1..d15e9e159 100644 --- a/lib/hive/commands/daemon.rb +++ b/lib/hive/commands/daemon.rb @@ -371,6 +371,7 @@ module Hive if @json service_state = probe_service_state + consistency = consistency_payload(pid: running ? pid : nil) puts JSON.generate( "schema" => "hive-daemon-status", "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-daemon-status"), @@ -383,6 +384,12 @@ module Hive "service_installed" => service_state["service_installed"], "service_enabled" => service_state["service_enabled"], "unit_path" => service_state["unit_path"], + # Binary-consistency guard (U5): the resolved binary the installer + # would write, the CLI version, and whether the installed unit and + # running process agree with both. + "binary" => consistency["binary"], + "binary_version" => consistency["binary_version"], + "consistent" => consistency["consistent"], # Agent-native parity with the TUI footer / bot push: expose the # update nudge so a programmatic caller can detect "behind" too. "current_version" => Hive::VERSION, @@ -397,6 +404,16 @@ module Hive raise Hive::Error, "daemon not running" unless running end + # Read-only binary-consistency probe for the status envelope. Any + # failure degrades the three fields to null instead of raising out of + # the whole command (mirrors probe_service_state). + def consistency_payload(pid:) + require "hive/daemon/consistency" + Hive::Daemon::Consistency.new(pid: pid).probe + rescue StandardError + { "binary" => nil, "binary_version" => Hive::VERSION, "consistent" => nil } + end + # Read-only autostart-state snapshot for the status envelope. A status # probe must never take down the running/pid reporting that precedes # it, so any failure degrades the three service fields to null (the diff --git a/lib/hive/commands/daemon/service_installer.rb b/lib/hive/commands/daemon/service_installer.rb index 02cda2fbf..5e0d2aac2 100644 --- a/lib/hive/commands/daemon/service_installer.rb +++ b/lib/hive/commands/daemon/service_installer.rb @@ -44,11 +44,14 @@ module Hive template = File.read(File.expand_path("../../../../examples/systemd/hive-daemon.service", __dir__)) # systemd .service files are POSIX-shell-ish — escape the # resolved binary path so whitespace, `%`, or other special - # characters don't produce a malformed unit. + # characters don't produce a malformed ExecStart=. Environment= + # does NOT parse shell backslash/quote escaping, so HIVE_BIN keeps + # the RAW path (the daemon reads ENV["HIVE_BIN"] verbatim into an + # argv element; an escaped value would point at a nonexistent file). escaped = Shellwords.escape(resolved_binary) template .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} daemon start") - .sub(/^Environment=HIVE_BIN=.*$/, "Environment=HIVE_BIN=#{escaped}") + .sub(/^Environment=HIVE_BIN=.*$/, "Environment=HIVE_BIN=#{resolved_binary}") .sub(/^Environment=PATH=.*$/, build_path_line) end diff --git a/lib/hive/commands/service_installer/base.rb b/lib/hive/commands/service_installer/base.rb index 04f9ba760..08701f865 100644 --- a/lib/hive/commands/service_installer/base.rb +++ b/lib/hive/commands/service_installer/base.rb @@ -126,6 +126,15 @@ module Hive nil end + # Public accessor for the resolved binary the installer would bake into + # the unit. The daemon consistency guard (Hive::Daemon::Consistency) + # compares the installed unit and the running process against this, so + # drift detection reuses the exact resolution the installer writes + # (brew stable symlink → binary_path → PATH → source fallback). + def resolved_binary + resolve_binary + end + private def install_macos!(autostart:, force:) @@ -305,7 +314,7 @@ module Hive nil end - def resolved_binary + def resolve_binary if (brew_binary = homebrew_stable_binary) return File.expand_path(brew_binary) end diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb new file mode 100644 index 000000000..4931df62e --- /dev/null +++ b/lib/hive/commands/setup.rb @@ -0,0 +1,296 @@ +require "json" +require "fileutils" +require "stringio" +require "hive/config" +require "hive/paths" +require "hive/invoked_binary" +require "hive/commands/setup/backend_prompt" +require "hive/commands/setup/deps" + +module Hive + module Commands + # `hive setup` — one-shot provisioning + validation of the first-class + # LOCAL install surface (Linux systemd-user / macOS launchd). It wires the + # sub-pieces in dependency order: backend selection → dependency check → + # Hive-owned deps (qmd + web bundle) → daemon service → project enrollment + # → web service. Every step is idempotent; external agent CLIs are + # diagnosed (never installed/authenticated), and each failure carries the + # exact fix command. + # + # Collaborators (deps / web_bundle / runner / health_probe) are injectable + # so the orchestration order is unit-testable without real subprocesses. + class Setup + # How long the web health probe keeps retrying connection failures + # before giving up. First-run Rails boot + db:prepare after + # `hive web install` can exceed a single 5s timeout, so a bounded + # retry loop (not a single unbounded-by-retry probe) absorbs it. + WEB_HEALTH_PROBE_DEADLINE_SECONDS = 30 + + def initialize(json: false, non_interactive: false, project_path: Dir.pwd, + input: $stdin, output: $stderr, summary_io: $stdout, + deps: nil, web_bundle: nil, runner: nil, health_probe: nil) + @json = json + @non_interactive = non_interactive + @project_path = File.expand_path(project_path) + @input = input + @output = output + @summary_io = summary_io + @deps = deps + @web_bundle = web_bundle + @runner = runner || method(:run_real) + @health_probe = health_probe || method(:default_health_probe) + end + + def call + backends = select_backends + persist_backends(backends) + + steps = [] + warnings = [] + + dep_results = deps.check + steps << { "name" => "deps", "ok" => true, "message" => "checked #{dep_results.size} dependencies" } + dep_results.select(&:blocked).each { |r| warnings << "#{r.name}: #{r.fix}" } + + qmd = bootstrap_qmd + steps << { "name" => "qmd", "ok" => qmd[:ok], "message" => qmd[:message] } + + bundle = web_bundle.provision + steps << { "name" => "web_bundle", "ok" => bundle[:ok], "message" => bundle[:message] } + + daemon = ensure_daemon + steps << { "name" => "daemon", "ok" => daemon[:ok], "message" => daemon[:message] } + + enroll = enroll_project + steps << { "name" => "enroll", "ok" => enroll[:ok], "message" => enroll[:message] } + + web = ensure_web_service + steps << { "name" => "web", "ok" => web[:ok], "message" => web[:message] } + + # Success = every step passed AND every non-blocked dependency is ok. + # Blocked external CLIs (claude/codex/gh) are diagnose-only warnings — + # they never gate the local web bring-up. + ok = dep_results.all? { |r| r.ok || r.blocked } && steps.all? { |s| s["ok"] } + + if @json + puts JSON.generate( + hive_setup_envelope(backends, dep_results, steps, warnings, ok) + ) + else + print_human_summary(backends, dep_results, steps, warnings, ok) + end + + raise Hive::Error, "hive setup incomplete — see the summary above" unless ok + + ok + end + + private + + def deps + @deps ||= Hive::Commands::Setup::Deps.new + end + + def web_bundle + @web_bundle ||= begin + require "hive/commands/setup/web_bundle" + Hive::Commands::Setup::WebBundle.new + end + end + + def hive_bin + @hive_bin ||= (Hive::InvokedBinary.path || "hive") + end + + def select_backends + input = @non_interactive ? StringIO.new : @input + Hive::Commands::Setup::BackendPrompt.new( + input: input, + output: @output, + # In JSON mode the summary line must not land on stdout — it + # would corrupt the single-document contract (U3). Route it to + # @output ($stderr) instead of @summary_io ($stdout). + summary_io: @json ? @output : @summary_io + ).collect + end + + def persist_backends(backends) + Hive::Config.write_global_agents!(backends) + backends + end + + # `qmd` (the Hive-managed wiki indexer) is bootstrapped into Hive's own + # data prefix, mirroring install.sh's managed npm install contract. Never + # touches the user's global npm prefix or authenticates anything. + def bootstrap_qmd + qmd_home = File.join(Hive::Paths.data_home, "qmd") + qmd_bin = File.join(qmd_home, "bin", "qmd") + return { ok: true, message: "qmd already installed" } if File.executable?(qmd_bin) + + _out, npm_ok = @runner.call(%w[npm --version]) + return { ok: false, message: "npm missing — install Node.js/npm, then re-run hive setup" } unless npm_ok + + FileUtils.mkdir_p(qmd_home) + package = ENV["HIVE_QMD_NPM_PACKAGE"] || "@tobilu/qmd" + _out, ok = @runner.call([ "npm", "install", "--global", "--prefix", qmd_home, + "--no-audit", "--no-fund", package ]) + return { ok: false, message: "qmd install failed; run: npm install --global --prefix #{qmd_home} #{package}" } unless ok + + link_qmd(qmd_home, qmd_bin) + { ok: true, message: "qmd installed to #{qmd_home}" } + end + + def link_qmd(qmd_home, qmd_bin) + bin_dir = Hive::Paths.bin_home + FileUtils.mkdir_p(bin_dir) + link = File.join(bin_dir, "qmd") + FileUtils.ln_sf(qmd_bin, link) + rescue StandardError + nil + end + + def ensure_daemon + _out, install_ok = @runner.call([ hive_bin, "daemon", "install", "--force" ]) + return { ok: false, message: "hive daemon install --force failed" } unless install_ok + + # `hive daemon status --json` exits 1 when the daemon is not running + # but STILL emits its JSON envelope on stdout, so the exit code is + # not a command-failure signal. Parse the envelope unconditionally: + # a systemd-less host (install → autostart_unavailable) or a restart + # race right after `install --force` must not fail setup just because + # the daemon briefly reports "not running". A truly broken status + # command yields invalid JSON and fails below. + status_out, = @runner.call([ hive_bin, "daemon", "status", "--json" ]) + + begin + payload = JSON.parse(status_out) + rescue JSON::ParserError + return { ok: false, message: "hive daemon status returned invalid JSON" } + end + + # U5 consistency guard: false = drifted binary/version, true = + # verified consistent, nil = the probe could not verify (e.g. /proc + # and ps both unavailable). nil must NOT be reported as pass — + # unverifiable drift is not "consistent". + case payload["consistent"] + when false + return { ok: false, message: "daemon binary is inconsistent with the CLI; run `hive daemon install --force`" } + when nil + return { ok: false, message: "daemon consistency could not be verified; run `hive daemon status --json` to inspect" } + end + + { ok: true, message: "daemon service installed and consistent" } + end + + def enroll_project + registered = Hive::Config.registered_projects.any? do |p| + File.expand_path(p["path"]) == @project_path + end + + if registered + name = Hive::Config.registered_projects.find { |p| File.expand_path(p["path"]) == @project_path }["name"] + _out, ok = @runner.call([ hive_bin, "daemon", "enable", name ]) + return { ok: false, message: "hive daemon enable #{name} failed" } unless ok + + { ok: true, message: "enrolled #{name} in the daemon" } + else + _out, ok = @runner.call([ hive_bin, "init", @project_path, "--force" ]) + return { ok: false, message: "hive init #{@project_path} failed" } unless ok + + { ok: true, message: "initialized #{@project_path}" } + end + end + + def ensure_web_service + _out, install_ok = @runner.call([ hive_bin, "web", "install" ]) + return { ok: false, message: "hive web install failed" } unless install_ok + + # Persist the local loopback web contract (U1/R4). `hive web install` + # (the subprocess above) writes it too, but the in-process write is + # what keeps this observable when the runner is injected/stubbed and + # guarantees a fresh `hive setup` never leaves web.auth_mode at the + # "owner" default. + Hive::Config.write_local_web_loopback! + + ok, message = @health_probe.call + { ok: ok, message: message } + end + + # Bounded HTTP probe with retry: open_timeout caps the TCP connect and + # read_timeout caps the response wait, so a hung web server (or a port + # that accepts but never answers) cannot hang `hive setup` + # indefinitely. The probe targets the RESOLVED web_url (so a + # non-default configured port is probed too, not the hardcoded + # 127.0.0.1:4567), and retries connection failures up to + # WEB_HEALTH_PROBE_DEADLINE_SECONDS because first-run Rails boot + + # db:prepare can exceed a single 5s timeout. + def default_health_probe + require "net/http" + base = web_url + uri = URI("#{base}/health?deep=1") + + deadline = Time.now + WEB_HEALTH_PROBE_DEADLINE_SECONDS + last_error = nil + loop do + begin + res = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 5) do |http| + http.request(Net::HTTP::Get.new(uri)) + end + ok = res.is_a?(Net::HTTPSuccess) + return [ ok, ok ? "web health OK at #{base}" : "web health returned #{res.code}" ] + rescue StandardError => e + last_error = e + end + break if Time.now >= deadline + + sleep 1 + end + + [ false, "web health probe failed: #{last_error ? "#{last_error.class}: #{last_error.message}" : 'no response'}" ] + end + + def run_real(argv) + require "open3" + out, _err, status = Open3.capture3(*argv) + [ out, status.success? ] + rescue Errno::ENOENT, Errno::EACCES + [ "", false ] + end + + def web_url + cfg = Hive::Config.load_global_web + "http://#{cfg.fetch('bind')}:#{cfg.fetch('port')}" + rescue Hive::ConfigError + "http://127.0.0.1:4567" + end + + def hive_setup_envelope(backends, dep_results, steps, warnings, ok) + { + "schema" => "hive-setup", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"), + "ok" => ok, + "backends" => backends, + "deps" => dep_results.map(&:to_h), + "steps" => steps, + "web_url" => web_url, + "warnings" => warnings + } + end + + def print_human_summary(backends, dep_results, steps, warnings, ok) + @summary_io.puts "hive setup: backends = #{backends.join(', ')}" + dep_results.each do |r| + mark = r.ok ? "ok" : (r.blocked ? "blocked" : "missing") + line = " #{mark.ljust(7)} #{r.name.ljust(9)} #{r.message}" + line += " (#{r.fix})" if r.fix + @summary_io.puts line + end + steps.each do |s| + @summary_io.puts " #{s['ok'] ? 'ok' : 'FAILED'.ljust(6)} #{s['name'].ljust(11)} #{s['message']}" + end + warnings.each { |w| @summary_io.puts " warn: #{w}" } + @summary_io.puts(ok ? "hive setup: complete — web UI at #{web_url}" : "hive setup: incomplete") + end + end + end +end diff --git a/lib/hive/commands/setup/deps.rb b/lib/hive/commands/setup/deps.rb new file mode 100644 index 000000000..c54260702 --- /dev/null +++ b/lib/hive/commands/setup/deps.rb @@ -0,0 +1,254 @@ +require "open3" + +module Hive + module Commands + class Setup + # Dependency checker for `hive setup`. Verifies the local surface's + # required CLIs and reports a per-dep Result. Two classes of dependency: + # + # * External agent CLIs (gh / claude / codex) are DIAGNOSE-ONLY: a + # missing or unauthenticated CLI is reported `blocked` with the + # exact fix command, but `hive setup` NEVER installs or + # authenticates them (R10). + # * Everything else (ruby/git/tmux/node/npm/sqlite3) is a hard check — + # missing → ok:false with a fix command, but not "blocked" (the + # operator runs the fix and re-runs setup; setup does not gate on + # them itself). + # + # `qmd` and the web bundle are Hive-owned and bootstrapped by the setup + # orchestrator (U3/U4), not diagnosed here. + class Deps + MIN_RUBY = "3.4.0".freeze + MIN_CLAUDE = "2.1.118".freeze + MIN_CODEX = "0.125.0".freeze + + # Ruby version managers and their install + select commands. The + # `root` segments mirror ServiceInstaller::Base::RUBY_SHIM_MANAGERS + # keys so the detector here and the unit-PATH builder agree on what + # counts as mise / rbenv / asdf. `3.4` is the version token each + # manager resolves to the latest 3.4.x. + RUBY_MANAGER_FIXES = { + "mise" => { root: ".local/share/mise", fix: "mise install ruby@3.4 && mise use ruby@3.4" }, + "rbenv" => { root: ".rbenv", fix: "rbenv install 3.4 && rbenv global 3.4" }, + "asdf" => { root: ".asdf", fix: "asdf install ruby 3.4 && asdf global ruby 3.4" } + }.freeze + + # Fallback when no manager is detectable (system Ruby, or Ruby not + # yet installed with no manager CLI on PATH): point at every supported + # manager rather than a single hardcoded one. + SYSTEM_RUBY_FIX = "install Ruby 3.4 with rbenv / mise / asdf, or your OS package manager".freeze + + # One row of the dependency report. `ok` = present and satisfies the + # gate; `blocked` = an external CLI that setup cannot fix itself + # (missing/unauth); `found` = parsed version (or nil); `fix` = exact + # command the operator must run (nil when ok). + Result = Struct.new(:name, :ok, :blocked, :found, :required, :message, :fix, keyword_init: true) do + def to_h + { + "name" => name, + "ok" => ok, + "blocked" => blocked, + "found" => found, + "required" => required, + "message" => message, + "fix" => fix + } + end + end + + # `runner` is callable with an argv Array and returns [stdout, ok]. + # Injectable so tests can stub `claude`/`codex`/`gh`/`qmd` on PATH. + def initialize(runner: nil) + @runner = runner || method(:run_real) + end + + def check + [ + check_ruby, + check_git, + check_tmux, + check_gh, + check_claude, + check_codex, + check_node, + check_sqlite3 + ] + end + + def blocked_names + check.select(&:blocked).map(&:name) + end + + def all_ok? + check.all?(&:ok) + end + + private + + def run_real(argv) + out, _err, status = Open3.capture3(*argv) + [ out, status.success? ] + rescue Errno::ENOENT, Errno::EACCES + [ "", false ] + end + + # Run `argv` and extract the first version-looking token. Returns + # [version, ok]; version is nil when the command failed or produced + # no parseable version. + def probe_version(argv, pattern) + out, ok = @runner.call(argv) + return [ nil, false ] unless ok + + match = out.to_s[pattern] + [ match, !match.nil? ] + end + + def version_tuple(version) + version.to_s.split(".").map(&:to_i) + end + + def at_least?(found, min) + (version_tuple(found) <=> version_tuple(min)) >= 0 + end + + # Ruby 3.4 is exact (major.minor), not a floor — a 3.5/4.x Ruby would + # drift from the pinned .ruby-version / Gemfile.lock and fail the + # bundle later anyway, so say so up front. + def ruby_major_minor_match?(version) + tuple = version_tuple(version) + tuple[0] == 3 && tuple[1] == 4 + end + + def check_ruby + version, ok = probe_version(%w[ruby --version], /\d+\.\d+\.\d+/) + unless ok && version + return Result.new(name: "ruby", ok: false, blocked: false, found: nil, required: "3.4.x", + message: "ruby 3.4 is required", + fix: ruby_fix) + end + unless ruby_major_minor_match?(version) + return Result.new(name: "ruby", ok: false, blocked: false, found: version, required: "3.4.x", + message: "ruby #{version} found; 3.4.x is required", + fix: ruby_fix) + end + Result.new(name: "ruby", ok: true, blocked: false, found: version, required: "3.4.x", + message: "ok", fix: nil) + end + + # The fix command for a missing / wrong-version Ruby, tailored to the + # version manager actually in use (mise / rbenv / asdf), with a + # manager-agnostic fallback for system Ruby. Mirrors + # ServiceInstaller::Base#ruby_shim_dir's detection so the two surfaces + # agree on which manager is active. + def ruby_fix + RUBY_MANAGER_FIXES.dig(ruby_manager, :fix) || SYSTEM_RUBY_FIX + end + + # Detect the active Ruby version manager: prefer the realpath of the + # `ruby` on PATH (matched against each manager's install root), + # falling back to whichever manager CLI is itself on PATH when Ruby + # isn't installed yet. + def ruby_manager + ruby_path = which("ruby") + if ruby_path + resolved = File.exist?(ruby_path) ? File.realpath(ruby_path) : ruby_path + home = File.expand_path(ENV["HOME"] || Dir.home) + RUBY_MANAGER_FIXES.each do |name, spec| + root = File.join(home, spec[:root]) + return name if resolved.start_with?("#{root}/") + end + end + + RUBY_MANAGER_FIXES.keys.find { |name| which(name) } + end + + # Pure PATH lookup (no spawn) — the same predicate + # ServiceInstaller::Base uses to resolve binaries. + def which(name) + ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir| + path = File.join(dir, name) + return path if File.file?(path) && File.executable?(path) + end + nil + end + + def check_git + version, ok = probe_version(%w[git --version], /\d+\.\d+(?:\.\d+)?/) + ok ? Result.new(name: "git", ok: true, blocked: false, found: version, required: "any", + message: "ok", fix: nil) + : Result.new(name: "git", ok: false, blocked: false, found: nil, required: "any", + message: "git not found", fix: "brew install git") + end + + def check_tmux + version, ok = probe_version(%w[tmux -V], /\d+\.\d+/) + ok ? Result.new(name: "tmux", ok: true, blocked: false, found: version, required: "any", + message: "ok", fix: nil) + : Result.new(name: "tmux", ok: false, blocked: false, found: nil, required: "any", + message: "tmux not found", fix: "brew install tmux") + end + + def check_gh + version, ok = probe_version(%w[gh --version], /\d+\.\d+(?:\.\d+)?/) + unless ok + return Result.new(name: "gh", ok: false, blocked: true, found: nil, required: "authenticated", + message: "gh not found", fix: "brew install gh && gh auth login") + end + unless gh_authed? + return Result.new(name: "gh", ok: false, blocked: true, found: version, required: "authenticated", + message: "gh is installed but not authenticated", fix: "gh auth login") + end + Result.new(name: "gh", ok: true, blocked: false, found: version, required: "authenticated", + message: "ok", fix: nil) + end + + def gh_authed? + _out, ok = @runner.call(%w[gh auth status]) + ok + end + + def check_claude + check_agent_cli("claude", MIN_CLAUDE) + end + + def check_codex + check_agent_cli("codex", MIN_CODEX) + end + + def check_agent_cli(name, min_version) + version, ok = probe_version([ name, "--version" ], /\d+\.\d+\.\d+/) + unless ok + return Result.new(name: name, ok: false, blocked: true, found: nil, required: ">= #{min_version}", + message: "#{name} not found", + fix: "brew install #{name} && #{name} login") + end + unless at_least?(version, min_version) + return Result.new(name: name, ok: false, blocked: true, found: version, required: ">= #{min_version}", + message: "#{name} #{version} below minimum #{min_version}", + fix: "brew upgrade #{name}") + end + Result.new(name: name, ok: true, blocked: false, found: version, required: ">= #{min_version}", + message: "ok", fix: nil) + end + + def check_node + node, node_ok = probe_version(%w[node --version], /\d+\.\d+\.\d+/) + npm, npm_ok = probe_version(%w[npm --version], /\d+\.\d+\.\d+/) + ok = node_ok && npm_ok + Result.new(name: "node/npm", ok: ok, blocked: false, found: ok ? "node #{node}, npm #{npm}" : nil, + required: "any", + message: ok ? "ok" : "node or npm not found", + fix: ok ? nil : "brew install node") + end + + def check_sqlite3 + version, ok = probe_version(%w[sqlite3 --version], /\d+\.\d+\.\d+/) + ok ? Result.new(name: "sqlite3", ok: true, blocked: false, found: version, required: "any", + message: "ok", fix: nil) + : Result.new(name: "sqlite3", ok: false, blocked: false, found: nil, required: "any", + message: "sqlite3 not found", fix: "brew install sqlite") + end + end + end + end +end diff --git a/lib/hive/commands/setup/web_bundle.rb b/lib/hive/commands/setup/web_bundle.rb new file mode 100644 index 000000000..f44d202e1 --- /dev/null +++ b/lib/hive/commands/setup/web_bundle.rb @@ -0,0 +1,167 @@ +require "fileutils" +require "open3" +require "hive/paths" + +module Hive + module Commands + class Setup + # Web bundle provisioning for the first-class LOCAL mode (U4). The gem + # does not package the Rails app (ADR-037), so `hive setup` obtains and + # bundles it separately, pinned to the installed gem version so the web + # tier and CLI agree. + # + # Resolution order: HIVEBOX_WEB_APP_DIR → managed `~/.local/share/hive/web` + # → sibling source checkout. When nothing resolves, fetch the release + # tarball for `v#{Hive::VERSION}` (git clone --depth 1 --branch fallback) + # into the managed dir, keep only `web/`, then `bundle install` + # (BUNDLE_GEMFILE=web/Gemfile) and `bin/rails db:prepare`. A marker file + # records the pinned version so `hive setup` / `hive web` can warn (and + # re-fetch) when the managed bundle drifts from `Hive::VERSION`. + class WebBundle + MARKER_FILENAME = ".hive-web-version".freeze + + # `fetcher` is callable with (target_dir) and returns {ok:, message:}; + # injectable so tests never touch the network. `runner` is callable + # with an argv Array and returns [stdout, ok]. + def initialize(fetcher: nil, runner: nil) + @fetcher = fetcher || method(:default_fetch) + @runner = runner || method(:run_real) + end + + # Provision the bundle. Returns { ok:, message:, app_dir: }. Idempotent: + # an already-present, version-matching bundle only re-runs the + # idempotent bundle/db steps; a stale managed bundle is re-fetched. + def provision + existing = resolve_app_dir + dir = existing || Hive::Paths.managed_web_dir + + if existing.nil? || stale_managed?(dir) + result = @fetcher.call(dir) + return { ok: false, message: result[:message], app_dir: dir } unless result[:ok] + end + + install = bundle_install(dir) + return { ok: false, message: install[:message], app_dir: dir } unless install[:ok] + + db = db_prepare(dir) + return { ok: false, message: db[:message], app_dir: dir } unless db[:ok] + + write_marker(dir) if managed_dir?(dir) + + { ok: true, message: "web bundle ready at #{dir}", app_dir: dir } + end + + # Resolution order: env override → managed dir → sibling source + # checkout. Only a dir holding `config/application.rb` counts. + def resolve_app_dir + candidates = [ + ENV["HIVEBOX_WEB_APP_DIR"], + Hive::Paths.managed_web_dir, + File.expand_path("../../../../web", __dir__) + ].compact + candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) } + end + + def marker_path(dir) + File.join(dir, MARKER_FILENAME) + end + + def write_marker(dir) + FileUtils.mkdir_p(dir) + File.write(marker_path(dir), "#{Hive::VERSION}\n") + end + + # The pinned version recorded in the marker, or nil when absent. + def marker_version(dir) + path = marker_path(dir) + return nil unless File.file?(path) + + value = File.read(path).strip + value.empty? ? nil : value + rescue SystemCallError + nil + end + + # True when the managed bundle's marker disagrees with Hive::VERSION. + def version_mismatch?(dir) + pinned = marker_version(dir) + !pinned.nil? && pinned != Hive::VERSION + end + + def managed_dir?(dir) + File.expand_path(dir) == File.expand_path(Hive::Paths.managed_web_dir) + end + + # A managed bundle is stale when it has no marker (an older setup + # before markers) or a marker that disagrees with Hive::VERSION. + def stale_managed?(dir) + return false unless managed_dir?(dir) + + marker_version(dir) != Hive::VERSION + end + + private + + def bundle_install(dir) + in_dir(dir) do + _out, ok = @runner.call([ "bundle", "install" ]) + return { ok: false, message: "bundle install failed; run: cd #{dir} && bundle install" } unless ok + end + { ok: true, message: "bundle installed" } + end + + def db_prepare(dir) + in_dir(dir) do + _out, ok = @runner.call([ "bin/rails", "db:prepare" ]) + return { ok: false, message: "db:prepare failed; run: cd #{dir} && bin/rails db:prepare" } unless ok + end + { ok: true, message: "db prepared" } + end + + def in_dir(dir, &blk) + Dir.chdir(dir, &blk) + end + + # Fetch the matching release into the managed dir. Tries a shallow + # clone of the pinned tag first (git is a verified setup dep), keeps + # only `web/`, and records no marker (the caller writes it after a + # successful bundle). Returns {ok:, message:}. + def default_fetch(dir) + version = Hive::VERSION + tag = "v#{version}" + url = "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}.git" + parent = File.dirname(dir) + FileUtils.mkdir_p(parent) + tmp = File.join(parent, ".hive-web-src-#{Process.pid}-#{rand(1_000_000)}") + FileUtils.rm_rf(tmp) + + _out, ok = @runner.call([ "git", "clone", "--depth", "1", "--branch", tag, url, tmp ]) + unless ok + FileUtils.rm_rf(tmp) + return { ok: false, + message: "could not fetch web bundle #{tag}; run: git clone --depth 1 " \ + "--branch #{tag} #{url} /tmp/hive-web-src && mv /tmp/hive-web-src/web #{dir}" } + end + + web_src = File.join(tmp, "web") + unless File.directory?(web_src) + FileUtils.rm_rf(tmp) + return { ok: false, message: "release #{tag} has no web/ directory" } + end + + FileUtils.rm_rf(dir) + FileUtils.mv(web_src, dir) + FileUtils.rm_rf(tmp) + { ok: true, message: "fetched web bundle #{tag}" } + end + + def run_real(argv) + out, _err, status = Open3.capture3(*argv) + [ out, status.success? ] + rescue Errno::ENOENT, Errno::EACCES + [ "", false ] + end + end + end + end +end diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb index eb3cd40fc..2317dc90b 100644 --- a/lib/hive/commands/web.rb +++ b/lib/hive/commands/web.rb @@ -1,31 +1,91 @@ +require "json" +require "time" require "hive/config" +require "hive/paths" +require "hive/pid_file" require "hive/web/session_secret" module Hive module Commands - # Boots the hivebox web UI — a Rails app living in web/ at the repo root - # (shipped in the Docker image at /app/web). hive itself stays a plain - # CLI gem; the web tier is only supported where the Rails app and its - # bundle exist: the hivebox container or a source checkout. + # `hive web` boots the hivebox web UI — a Rails app living in web/ at the + # repo root (shipped in the Docker image at /app/web, or provisioned into + # the managed `~/.local/share/hive/web` dir by `hive setup`). hive itself + # stays a plain CLI gem; the web tier is only supported where the Rails + # app and its bundle exist. + # + # With no subcommand, `hive web` runs the server in the foreground. With a + # subcommand it manages a SEPARATE per-user service (systemd-user / + # launchd), distinct from `hive daemon`: + # + # install [--force] (re)write + enable the platform-native unit + # start run the web server detached (pidfile) + # stop SIGTERM the running web server + # status [--json] running/pid/uptime + service/install state class Web - def initialize(bind: nil, port: nil) + include Hive::PidFile + + VALID_SUBCOMMANDS = %w[install start stop status].freeze + + def initialize(subcommand = nil, bind: nil, port: nil, force: false, json: false, + hive_home: Hive::Paths.state_home) + @subcommand = subcommand @bind = bind @port = port + @force = force + @json = json + @hive_home = hive_home end def call + unless @subcommand.nil? || VALID_SUBCOMMANDS.include?(@subcommand) + raise Hive::InvalidTaskPath, + "hive web: unknown subcommand #{@subcommand.inspect} " \ + "(expected: #{VALID_SUBCOMMANDS.join(', ')})" + end + + case @subcommand + when nil then run_foreground + when "install" then install_web_service + when "start" then start_web_service + when "stop" then stop_web_service + when "status" then status_web_service + end + end + + def pid_file + @pid_file ||= File.join(@hive_home, ".web.pid") + end + + # Resolve the Rails app dir the same way both the foreground command and + # the ServiceInstaller do. Candidate order: HIVEBOX_WEB_APP_DIR → managed + # `~/.local/share/hive/web` (provisioned by `hive setup`) → sibling + # source checkout. A candidate only counts when it holds + # `config/application.rb`. + def self.rails_app_dir + candidates = [ + ENV["HIVEBOX_WEB_APP_DIR"], + Hive::Paths.managed_web_dir, + File.expand_path("../../../web", __dir__) + ].compact + candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) } + end + + def run_foreground cfg = Hive::Config.load_global_web bind = @bind || cfg.fetch("bind") port = (@port || cfg.fetch("port")).to_i app_dir = rails_app_dir unless app_dir warn "hive web: the hivebox web app (web/) was not found. " \ - "Run from the hivebox Docker image or a source checkout, " \ - "or point HIVEBOX_WEB_APP_DIR at the Rails app." + "Run `hive setup` to provision it, run from the hivebox Docker " \ + "image or a source checkout, or point HIVEBOX_WEB_APP_DIR at " \ + "the Rails app." exit 1 end warn_on_public_bind(bind, cfg) + refuse_unsafe_public_bind(bind, cfg) + warn_on_managed_bundle_mismatch(app_dir) env = { "RAILS_ENV" => ENV.fetch("RAILS_ENV", "production"), @@ -64,12 +124,155 @@ module Hive private + def web_config + @web_config ||= Hive::Config.load_global_web + end + + def resolved_bind + (@bind || web_config.fetch("bind")).to_s + end + + def resolved_port + (@port || web_config.fetch("port")).to_i + end + + def install_web_service + require "hive/commands/web/service_installer" + installer = Hive::Commands::Web::ServiceInstaller.new(binary_path: current_binary_path) + begin + result = installer.install!(autostart: true, force: @force) + rescue Hive::Error + raise + rescue StandardError => e + raise Hive::WebInstallFailed, + "web service install failed: #{e.class}: #{e.message}" + end + installer.messages.each { |line| warn "hive: #{line}" } + emit_install_summary(installer, result) + emit_install_outcome(installer, result) + # Persist the local loopback web contract (U1/R4) so a fresh + # `hive web install` is not left gated behind the GitHub owner flow. + Hive::Config.write_local_web_loopback! + end + + def emit_install_summary(installer, outcome) + case outcome.kind + when :written + puts "hive web: installed unit at #{installer.target_path}" + when :upgraded + msg = "hive web: upgraded unit at #{installer.target_path}" + msg += " (backup: #{outcome.backup_path})" if outcome.backup_path + puts msg + when :unchanged + puts "hive web: unit already up to date at #{installer.target_path}" + when :autostart_unavailable + puts "hive web: unit written at #{installer.target_path}; autostart not enabled on this host" + when :unsupported, :drifted, :failed + # handled by emit_install_outcome / installer.messages + end + end + + def emit_install_outcome(installer, outcome) + if outcome.drifted? + msg = "web unit at #{installer.target_path} differs from the current template. " \ + "Re-run with `hive web install --force` to overwrite (a timestamped .bak " \ + "will be saved)." + raise Hive::WebInstallDriftError, msg + elsif outcome.failed? + raise Hive::WebInstallFailed, + "web service install reported a failure; see messages above" + end + end + + def start_web_service + FileUtils.mkdir_p(@hive_home) + + if (existing = read_live_pid) + raise Hive::ConcurrentRunError.new( + "hive web already running (pid #{existing})", + holder: { pid: existing }, lock_path: pid_file + ) + end + + File.delete(pid_file) if File.exist?(pid_file) + + Process.daemon(true, true) + write_own_pid_file! + run_foreground + end + + def write_own_pid_file! + File.write(pid_file, pid_file_payload(Process.pid).to_yaml) + end + + def stop_web_service + pid = read_live_pid + unless pid + File.delete(pid_file) if File.exist?(pid_file) + return puts("hive web: not running") + end + + send_signal_safely(pid, :TERM) + deadline = Time.now + 30 + sleep 0.2 while pid_alive?(pid) && Time.now < deadline + send_signal_safely(pid, :KILL) if pid_alive?(pid) + File.delete(pid_file) if File.exist?(pid_file) + puts("hive web: stopped (pid #{pid})") + end + + def status_web_service + running = false + pid = nil + uptime_sec = nil + + if File.exist?(pid_file) + payload = read_pid_file_payload + pid = payload && payload["pid"] + if pid && pid > 0 && pid_alive?(pid) && pid_owned_by_us?(payload, pid) + running = true + uptime_sec = (Time.now - File.stat(pid_file).mtime).to_i + end + end + + service_state = probe_service_state + payload = { + "schema" => "hive-web-status", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"), + "ok" => true, + "running" => running, + "pid" => running ? pid : nil, + "uptime_sec" => uptime_sec, + "pid_file" => pid_file, + "service_installed" => service_state["service_installed"], + "service_enabled" => service_state["service_enabled"], + "unit_path" => service_state["unit_path"], + "app_dir" => rails_app_dir || Hive::Paths.managed_web_dir, + "bind" => resolved_bind, + "port" => resolved_port + } + + if @json + puts JSON.generate(payload) + else + puts(running ? "hive web: running (pid #{pid}, uptime #{uptime_sec}s) at http://#{payload['bind']}:#{payload['port']}" : "hive web: not running") + end + raise Hive::Error, "web not running" if !running && !@json + end + + def probe_service_state + require "hive/commands/web/service_installer" + Hive::Commands::Web::ServiceInstaller.new.service_state + rescue StandardError + { "service_installed" => nil, "service_enabled" => nil, "unit_path" => nil } + end + + def current_binary_path + require "hive/invoked_binary" + Hive::InvokedBinary.path + end + def rails_app_dir - candidates = [ - ENV["HIVEBOX_WEB_APP_DIR"], - File.expand_path("../../../web", __dir__) - ].compact - candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) } + self.class.rails_app_dir end # Rails' production host authorization is inactive by default — the box @@ -83,6 +286,39 @@ module Hive warn "hive web: WARNING binding 0.0.0.0 without an https origin — " \ "ensure a trusted reverse proxy validates the Host header." end + + # U4 / R-skew mitigation: `hive web` warns when the managed bundle's + # pinned version marker disagrees with Hive::VERSION. Only the managed + # dir carries the marker; a source checkout or HIVEBOX_WEB_APP_DIR + # override is the operator's explicit choice and is not warned about. + def warn_on_managed_bundle_mismatch(app_dir) + return unless File.expand_path(app_dir) == File.expand_path(Hive::Paths.managed_web_dir) + + require "hive/commands/setup/web_bundle" + return unless Hive::Commands::Setup::WebBundle.new.version_mismatch?(app_dir) + + warn "hive web: the managed web bundle at #{app_dir} is pinned to a " \ + "different version than the installed hive gem (#{Hive::VERSION}); " \ + "re-run `hive setup` (or `hive web install --force`) to re-fetch it." + end + + # Local loopback auth policy guard (U1): loopback binds skip GitHub + # owner auth, but a NON-loopback bind under web.auth_mode=loopback + # would expose an ownerless web tier. Refuse loudly unless the operator + # explicitly sets web.unsafe_public_no_auth (or switches back to the + # owner flow). Docker/hivebox keeps binding 0.0.0.0 with the "owner" + # default, so this never fires there. + def refuse_unsafe_public_bind(bind, cfg) + return if Hive::Config.web_bind_loopback?(bind) + return unless Hive::Config.local_web_no_auth?(cfg) + return if cfg["unsafe_public_no_auth"] + + raise Hive::ConfigError, + "hive web: refusing to bind #{bind.inspect} with web.auth_mode=loopback " \ + "(no auth). Loopback no-auth is only safe on 127.0.0.0/8 or ::1. " \ + "Set web.auth_mode=owner (GitHub device flow), or set " \ + "web.unsafe_public_no_auth=true to accept the risk." + end end end end diff --git a/lib/hive/commands/web/service_installer.rb b/lib/hive/commands/web/service_installer.rb new file mode 100644 index 000000000..4afccc9d3 --- /dev/null +++ b/lib/hive/commands/web/service_installer.rb @@ -0,0 +1,88 @@ +require "cgi" +require "shellwords" +require "hive/commands/service_installer/base" +require "hive/commands/web" +require "hive/paths" + +module Hive + module Commands + class Web + # Per-user autostart installer for the managed local web service. The + # web tier is a SEPARATE service from the daemon (R7): it inherits the + # platform-agnostic mechanics (drift/backup, atomic write, shim-PATH + # detection, enable/load orchestration) from the shared base and + # supplies only the web identity and rendered unit/plist bodies. + # + # The one web-specific behavior is resolving the Rails app dir into the + # unit (`HIVEBOX_WEB_APP_DIR`) so the managed service survives + # login/reboot without relying on the caller's cwd — the gem does not + # package the Rails app (ADR-037), so `hive web` must be told where it + # was provisioned. + class ServiceInstaller < Hive::Commands::ServiceInstaller::Base + def service_name + "hive-web" + end + + def cli_label + "web" + end + + def service_noun + "web service" + end + + def unit_noun + "web unit" + end + + def target_path + case platform + when :macos then File.join(@home, "Library/LaunchAgents/local.hive-web.plist") + when :linux then File.join(@home, ".config/systemd/user/hive-web.service") + end + end + + # The Rails app dir baked into the unit. Prefer a real resolved app + # (env override / managed dir / sibling source checkout); fall back to + # the managed location when nothing exists yet so a fresh install + # still points `hive web` at the dir `hive setup` will populate. + def resolved_app_dir + Hive::Commands::Web.rails_app_dir || Hive::Paths.managed_web_dir + end + + private + + def render_systemd + template = File.read(File.expand_path("../../../../examples/systemd/hive-web.service", __dir__)) + escaped = Shellwords.escape(resolved_binary) + # ExecStart= parses shell-ish quoting, so the binary stays escaped. + # Environment= does NOT parse shell backslash/quote escaping (unlike + # ExecStart=), so the app dir is interpolated RAW — a path with + # spaces/special characters would otherwise be corrupted (matching + # the daemon installer's build_path_line, which leaves Environment= + # values unescaped). + app_dir = resolved_app_dir + template + .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} web --bind 127.0.0.1") + .sub(/^Environment=HIVEBOX_WEB_APP_DIR=.*$/, "Environment=HIVEBOX_WEB_APP_DIR=#{app_dir}") + .sub(/^Environment=PATH=.*$/, build_path_line) + end + + def render_launchd + template = File.read(File.expand_path("../../../../examples/launchd/hive-web.plist", __dir__)) + binary = resolved_binary + binary_dir = File.dirname(binary) + escaped_binary = CGI.escapeHTML(binary) + escaped_binary_dir = CGI.escapeHTML(binary_dir) + escaped_home = CGI.escapeHTML(@home) + escaped_app_dir = CGI.escapeHTML(resolved_app_dir) + template + .gsub(%r{/Users/YOU/\.local/bin/hive}, "#{escaped_binary}") + .gsub(%r{/Users/YOU/\.local/share/hive/web}, "#{escaped_app_dir}") + .gsub("/Users/YOU/Library/Logs", "#{escaped_home}/Library/Logs") + .gsub("/Users/YOU/.local/bin", escaped_binary_dir) + end + end + end + end +end diff --git a/lib/hive/config.rb b/lib/hive/config.rb index da8bd31af..daf9d0d30 100644 --- a/lib/hive/config.rb +++ b/lib/hive/config.rb @@ -2,6 +2,7 @@ require "yaml" require "fileutils" require "securerandom" require "pathname" +require "ipaddr" require "hive/agent_profiles" require "hive/babysitter/interval" require "hive/permission_scope" @@ -373,6 +374,15 @@ module Hive "bind" => "127.0.0.1", "port" => 4567, "origin" => "http://127.0.0.1:4567", + # Auth model for the web tier. "owner" (default) keeps the GitHub + # device-flow / owner-claim gate — the Docker/hivebox path, and the + # optional local opt-in. "loopback" means a loopback bind + # (127.0.0.0/8 or ::1) needs no login at all; `hive setup` / `hive + # web install` write this for the first-class local mode. A + # non-loopback bind under "loopback" is REFUSED at startup unless + # unsafe_public_no_auth is explicitly set (see Hive::Commands::Web). + "auth_mode" => "owner", + "unsafe_public_no_auth" => false, "github" => { "owner" => nil, # The shared hivebox OAuth app (device flow only — public by @@ -691,6 +701,34 @@ module Hive end end + # The accepted `web.auth_mode` values. "owner" is the Docker/hivebox + # GitHub owner-claim gate; "loopback" is the local no-auth mode gated on + # a loopback bind (see local_web_no_auth? and web_bind_loopback?). + WEB_AUTH_MODES = %w[owner loopback].freeze + + # True when the resolved web config opts into the local loopback + # no-auth policy (`web.auth_mode: loopback`). The Rails tier and the CLI + # both read this through the same resolved config, so request gating + # and startup refusal cannot drift. + def local_web_no_auth?(cfg) + cfg["auth_mode"] == "loopback" + end + + # Conservative loopback predicate for a bind string: `localhost` and the + # 127.0.0.0/8 + ::1 ranges are loopback; everything else (0.0.0.0, + # public IPs, other hostnames) is not. A nil/blank bind is treated as + # loopback — the resolved default is always 127.0.0.1. + def web_bind_loopback?(bind) + host = bind.to_s.strip + return true if host.empty? + return true if host.casecmp("localhost").zero? + + ip = IPAddr.new(host) + ip.loopback? + rescue IPAddr::InvalidAddressError + false + end + # argv fragment for hive-launched claude: ["--model", m, "--effort", e]. # Shared by the tmux wrapper and the headless Agent path. Semantics: # model "inherit"/blank omits the flag (operator's interactive default @@ -963,6 +1001,43 @@ module Hive (GLOBAL_AGENT_BACKENDS & selected).freeze end + # The first-class local web contract persisted by `hive setup` and + # `hive web install` (U1): a loopback bind needs no GitHub login, so + # writing auth_mode=loopback (plus the canonical bind/port) here is what + # makes the definition-of-done "no login on loopback" true for a fresh + # install instead of leaving web.auth_mode at the "owner" default. + LOCAL_WEB_CONTRACT = { + "auth_mode" => "loopback", + "bind" => "127.0.0.1", + "port" => 4567 + }.freeze + + # Persist a web config block into the global config, mirroring + # write_global_agents!. Only the keys explicitly passed are rewritten; + # operator-owned sibling keys (origin, github.client_id, + # unsafe_public_no_auth, …) are preserved. The merged result is + # validated against the same defaults/loader contract as + # load_global_web so a bad write can never persist an invalid block. + def write_global_web!(web) + update_global_config! do |data| + existing = data["web"] + unless existing.nil? || existing.is_a?(Hash) + raise ConfigError, "web in #{describe_source(global_config_path)} must be a Hash; got #{existing.class}" + end + + merged = (existing || {}).merge(web) + validate_web_config!({ "web" => deep_merge(global_web_defaults, merged) }, global_config_path) + data["web"] = merged + merged + end + end + + # Convenience wrapper that persists the local loopback web contract + # (auth_mode/bind/port). Called by `hive setup` and `hive web install`. + def write_local_web_loopback! + write_global_web!(LOCAL_WEB_CONTRACT) + end + # Shape gate shared by the loader and `prune`'s predicate so the # two surfaces agree on what counts as a valid registry row. # Without this, the loader would silently skip a corrupted entry @@ -2277,6 +2352,20 @@ module Hive "web.origin in #{describe_source(source_path)} must be an http(s) URL" end + auth_mode = web["auth_mode"] + unless WEB_AUTH_MODES.include?(auth_mode) + raise ConfigError, + "web.auth_mode in #{describe_source(source_path)} must be one of " \ + "#{WEB_AUTH_MODES.inspect}; got #{auth_mode.inspect} (#{auth_mode.class})" + end + + unsafe = web["unsafe_public_no_auth"] + unless unsafe == true || unsafe == false + raise ConfigError, + "web.unsafe_public_no_auth in #{describe_source(source_path)} must be true or false; " \ + "got #{unsafe.inspect} (#{unsafe.class})" + end + github = web["github"] unless github.is_a?(Hash) raise ConfigError, diff --git a/lib/hive/daemon/consistency.rb b/lib/hive/daemon/consistency.rb new file mode 100644 index 000000000..2786479d1 --- /dev/null +++ b/lib/hive/daemon/consistency.rb @@ -0,0 +1,133 @@ +require "open3" +require "shellwords" +require "hive/commands/daemon/service_installer" + +module Hive + module Daemon + # Daemon binary-consistency guard (U5). Detects a daemon whose installed + # unit (ExecStart / ProgramArguments) or running process points at a + # DIFFERENT binary or version than the CLI, and exposes the result as a + # read-only probe for `hive daemon status --json` and the web health card. + # + # The "correct" binary is the same one `ServiceInstaller::Base` would write + # (brew stable symlink → HIVE_INVOKED_BIN/binary_path → PATH → source + # fallback), so repair (`hive daemon install --force`) rewrites to exactly + # the target this probe compares against. + class Consistency + def initialize(installer: nil, pid: nil, exe_reader: nil, version_probe: nil) + @installer = installer || Hive::Commands::Daemon::ServiceInstaller.new + @pid = pid + @exe_reader = exe_reader || method(:read_process_exe) + @version_probe = version_probe || method(:read_binary_version) + end + + def expected_binary + @installer.resolved_binary + end + + # The binary baked into the installed unit's ExecStart / ProgramArguments, + # or nil when no unit exists / no binary could be parsed. + def unit_binary + path = @installer.target_path + return nil if path.nil? || !File.exist?(path) + + body = File.read(path) + case @installer.envelope_platform + when "linux" then systemd_unit_binary(body) + when "macos" then launchd_plist_binary(body) + end + rescue StandardError + nil + end + + def running_binary + return nil if @pid.nil? + + @exe_reader.call(@pid) + end + + # Read-only probe: { binary, binary_version, unit_binary, + # running_binary, running_version, consistent }. + def probe + expected = expected_binary + ub = unit_binary + rb = running_binary + rv = rb && @version_probe.call(rb) + + consistent = (ub.nil? || ub == expected) && + (rb.nil? || rb == expected) && + (rv.nil? || rv == Hive::VERSION) + + { + "binary" => expected, + "binary_version" => Hive::VERSION, + "unit_binary" => ub, + "running_binary" => rb, + "running_version" => rv, + "consistent" => consistent + } + end + + private + + def systemd_unit_binary(body) + line = body.lines.map(&:strip).find { |l| l.start_with?("ExecStart=") } + return nil unless line + + Shellwords.split(line.sub(/\AExecStart=/, "")).first + end + + def launchd_plist_binary(body) + strings = body.scan(%r{([^<]+)}).flatten + strings.find { |s| s.match?(%r{/(?:hive|hv)\z}) } + end + + # Resolve the running daemon's executable. `bin/hive` is a + # `#!/usr/bin/env ruby` script (and the daemon runs in-process as Ruby), + # so /proc//exe resolves to the ruby interpreter, never the hive + # script. Read /proc//cmdline (NUL-separated argv) instead — it + # carries the actual script path — and return the `hive`/`hv` script + # argument. Falls back to `ps -o args= -p ` (same script-argument + # scan) when /proc is absent. + def read_process_exe(pid) + cmdline = "/proc/#{pid}/cmdline" + return read_exe_via_ps(pid) unless File.exist?(cmdline) + + argv = File.read(cmdline).split("\0") + script = argv.find { |arg| hive_script_argument?(arg) } + script || read_exe_via_ps(pid) + rescue SystemCallError + read_exe_via_ps(pid) + end + + def read_exe_via_ps(pid) + out, status = Open3.capture2("ps", "-o", "args=", "-p", pid.to_s) + return nil unless status.success? + + script = out.strip.split(/\s+/).find { |tok| hive_script_argument?(tok) } + script + rescue StandardError + nil + end + + # True when the argument is the hive launcher (bin/hive) or the hv + # fallback launcher. Rejects argv[0] (`ruby`) and the subcommand tokens + # (`daemon`, `start`), so the cmdline/ps scan selects the script path + # regardless of where the interpreter places it. + def hive_script_argument?(arg) + return false if arg.nil? || arg.empty? + + %w[hive hv].include?(File.basename(arg)) + end + + def read_binary_version(binary) + out, status = Open3.capture2(binary, "--version") + return nil unless status.success? + + out[/\d+\.\d+\.\d+/] + rescue StandardError + nil + end + end + end +end diff --git a/lib/hive/paths.rb b/lib/hive/paths.rb index 752a5c7dc..e2d3be254 100644 --- a/lib/hive/paths.rb +++ b/lib/hive/paths.rb @@ -20,6 +20,13 @@ module Hive hive_home_override || File.join(base_home("XDG_CACHE_HOME", ".cache"), "hive") end + # Where `hive setup` provisions the web bundle for the first-class local + # mode (the gem does not package the Rails app — ADR-037). Lives under + # XDG data so it survives login/reboot and stays out of the git repo. + def managed_web_dir + File.join(data_home, "web") + end + def task_counter_path File.join(state_home, "task-counter.yml") end diff --git a/openclaw/skills/hive/SKILL.md b/openclaw/skills/hive/SKILL.md index 4eecc409c..78de50eec 100644 --- a/openclaw/skills/hive/SKILL.md +++ b/openclaw/skills/hive/SKILL.md @@ -4,7 +4,7 @@ description: >- Run Hive's folder-based coding-agent pipeline from OpenClaw: guided CLI setup, project init, task creation, plan/develop/review workflows, status, daemon, and guarded admin commands. -version: 0.1.1 +version: 0.1.2 user-invocable: true metadata: openclaw: @@ -23,7 +23,7 @@ metadata: Hive turns a repository into a folder-based coding-agent pipeline: ideas become tasks, tasks move through brainstorm, plan, develop, review, artifacts, and finalize stages, and the daemon keeps enrolled projects moving in the background. -Use this skill when the user wants to install Hive from OpenClaw, initialize the current project, create a task, inspect status, move a task through plan/develop/review, run diagnostics, start the Hivebox web UI, compile wiki changelog fragments, or administer Hive's daemon, bot, markers, metrics, and task registry. +Use this skill when the user wants to install Hive from OpenClaw, initialize the current project, create a task, inspect status, move a task through plan/develop/review, run diagnostics, start the local web UI (foreground or as a managed service), compile wiki changelog fragments, or administer Hive's daemon, bot, markers, metrics, and task registry. ## Install From ClawHub @@ -41,11 +41,11 @@ That listing installs the `/hive` slash command. First run should normally be: ## Common Paths -- `/hive setup` installs or verifies the Hive CLI, enables the per-user daemon service, and optionally initializes the current repository. +- `/hive setup` installs or verifies the Hive CLI, provisions the local surface (web bundle + daemon service + web service), and optionally initializes the current repository. - `/hive status --json` shows the task board and next actions. - `/hive new . "build this feature"` creates a new Hive task in the current project. - `/hive plan `, `/hive develop `, and `/hive review ` advance a task through the main coding workflow. -- `/hive web` starts the Hivebox browser surface when a user wants the local web UI. +- `/hive web` runs the local web UI in the foreground; `/hive web install` writes/enables the per-user systemd/launchd service (separate from the daemon), `/hive web start|stop|status` manage the lifecycle. On the loopback bind (`127.0.0.1:4567`) the web UI needs no GitHub login by default. - `/hive wiki compile-log --check` verifies that `wiki/log.md` matches the fragments in `wiki/log.d/`. - `/hive doctor` checks local runtime and skill configuration. @@ -81,7 +81,7 @@ curl -fsSL https://raw.githubusercontent.com/ivankuznetsov/hive/v0.2.0/install.s bash "$tmpdir/hive-install.sh" ``` -After install, run the strict `hive` / `hv` version check again. If neither command prints a bare `X.Y.Z` version, stop and report that setup failed or Apache Hive may be shadowing the command. If verification succeeds, run `"${hive_cmd}" daemon install` once. Then ask whether to initialize the current project; if yes, run `"${hive_cmd}" init . --json "$PREFIX/web-status.json" 2>"$PREFIX/web-status.err" +WEB_STATUS_RC=$? +set -e +WEB_SCHEMA="$(jq -r '.schema // empty' "$PREFIX/web-status.json" 2>/dev/null || true)" +if [[ "$WEB_SCHEMA" == "hive-web-status" ]]; then + ok "web status envelope schema=hive-web-status (rc=$WEB_STATUS_RC)" + WEB_BIND="$(jq -r '.bind // empty' "$PREFIX/web-status.json" 2>/dev/null || true)" + WEB_PORT="$(jq -r '.port // empty' "$PREFIX/web-status.json" 2>/dev/null || true)" + if [[ "$WEB_BIND" == "127.0.0.1" ]]; then ok "web status bind=127.0.0.1" + else fail "web status bind='$WEB_BIND', want 127.0.0.1"; fi + if [[ "$WEB_PORT" == "4567" ]]; then ok "web status port=4567" + else fail "web status port='$WEB_PORT', want 4567"; fi +else + log "web status — SKIPPED (release predates the `hive web` subcommand)" +fi + +# Feature-detect `hive setup` by asking Thor for its help: the command +# exists when `--help` exits 0 (a missing command exits non-zero). +set +e +"$XDG_BIN_HOME/hive" setup --help >/dev/null 2>&1 +SETUP_HELP_RC=$? +set -e +if [[ "$SETUP_HELP_RC" -eq 0 ]]; then + step "hive setup --json (walk provisioning; external CLIs/network may be absent in CI)" + # `hive setup` runs every step before reporting, so even a partial + # bring-up must emit the single hive-setup envelope (ok=false + per-step + # detail) on stdout and then exit non-zero. Run from the scratch project + # (already registered) so enrollment exercises the enable path, pipe empty + # stdin for the non-interactive backend defaults, and bound the whole walk + # against a hung network-bound dep (npm install / web-bundle git clone). + set +e + ( cd "$PROJECT" \ + && timeout 240 "$XDG_BIN_HOME/hive" setup --json "$PREFIX/setup.json" 2>"$PREFIX/setup.err" ) + SETUP_RC=$? + set -e + SETUP_SCHEMA="$(jq -r '.schema // empty' "$PREFIX/setup.json" 2>/dev/null || true)" + if [[ "$SETUP_SCHEMA" == "hive-setup" ]]; then + SETUP_OK="$(jq -r '.ok | tostring' "$PREFIX/setup.json" 2>/dev/null || true)" + ok "setup envelope schema=hive-setup ok=$SETUP_OK (rc=$SETUP_RC)" + SETUP_WEB_URL="$(jq -r '.web_url // empty' "$PREFIX/setup.json" 2>/dev/null || true)" + if [[ -n "$SETUP_WEB_URL" ]]; then ok "setup envelope carries web_url=$SETUP_WEB_URL" + else fail "setup envelope missing web_url"; fi + elif [[ "$SETUP_RC" -eq 124 ]]; then + log "setup --json timed out (network-bound deps in CI); envelope walk skipped" + else + cat "$PREFIX/setup.err" >&2 2>/dev/null || true + fail "setup did not emit a hive-setup envelope (rc=$SETUP_RC)" + fi +else + log "step: hive setup — SKIPPED (pinned release predates the command)" +fi + +# ─── 6. uninstall ──────────────────────────────────────────────────── if [[ $RUN_UNINSTALL -eq 1 ]]; then step "hive uninstall" @@ -588,7 +653,7 @@ if [[ $RUN_UNINSTALL -eq 1 ]]; then fi fi -# ─── 6. leak detection ─────────────────────────────────────────────── +# ─── 7. leak detection ─────────────────────────────────────────────── # Verify that nothing the install/test sequence wrote leaked outside # the prefix. We anchor on the real (un-mocked) user's HOME — but diff --git a/schemas/hive-daemon-status.v1.json b/schemas/hive-daemon-status.v1.json index 1ee77d0d2..ae6bc60e9 100644 --- a/schemas/hive-daemon-status.v1.json +++ b/schemas/hive-daemon-status.v1.json @@ -22,6 +22,9 @@ "service_installed", "service_enabled", "unit_path", + "binary", + "binary_version", + "consistent", "current_version", "update_nudge" ], @@ -61,6 +64,18 @@ "type": ["string", "null"], "description": "Absolute path of the autostart unit file; null on unsupported platforms or if the probe could not run." }, + "binary": { + "type": ["string", "null"], + "description": "The resolved binary the service installer would write (the CLI's own binary)." + }, + "binary_version": { + "type": "string", + "description": "The running hive version (Hive::VERSION) the consistency guard compares against." + }, + "consistent": { + "type": ["boolean", "null"], + "description": "True when the installed unit's binary and the running daemon's executable/version all match the CLI. Null only when the probe could not run." + }, "current_version": { "type": "string", "description": "The running hive version, so a caller can compare against update_nudge.latest itself." diff --git a/schemas/hive-setup.v1.json b/schemas/hive-setup.v1.json new file mode 100644 index 000000000..7abdc304b --- /dev/null +++ b/schemas/hive-setup.v1.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-setup.v1.json", + "title": "hive setup output (v1)", + "description": "Stable contract emitted by `hive setup --json`. Reports the resolved agent backends, the per-dependency report, and the ordered provisioning steps (deps → qmd → web_bundle → daemon → enroll → web). `ok` is true when every step passed and every non-blocked dependency is ok; blocked external agent CLIs (claude/codex/gh) are surfaced as warnings, never gate the local web bring-up.", + "oneOf": [ + { "$ref": "#/$defs/SuccessPayload" } + ], + "$defs": { + "SuccessPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "backends", + "deps", + "steps", + "web_url", + "warnings" + ], + "properties": { + "schema": { "const": "hive-setup" }, + "schema_version": { "const": 1 }, + "ok": { + "type": "boolean", + "description": "True when every step passed and every non-blocked dependency is ok." + }, + "backends": { + "type": "array", + "items": { "type": "string" }, + "description": "The resolved global agent backends persisted by the setup prompt." + }, + "deps": { + "type": "array", + "items": { "$ref": "#/$defs/Dep" }, + "description": "Per-dependency report in check order." + }, + "steps": { + "type": "array", + "items": { "$ref": "#/$defs/Step" }, + "description": "Ordered provisioning steps." + }, + "web_url": { + "type": "string", + "description": "The loopback URL the web UI serves at after a successful setup." + }, + "warnings": { + "type": "array", + "items": { "type": "string" }, + "description": "Diagnose-only notices for blocked external agent CLIs, each carrying the exact fix command." + } + } + }, + "Dep": { + "type": "object", + "additionalProperties": false, + "required": ["name", "ok", "blocked", "found", "required", "message", "fix"], + "properties": { + "name": { "type": "string" }, + "ok": { "type": "boolean" }, + "blocked": { + "type": "boolean", + "description": "True for an external agent CLI (gh/claude/codex) that setup cannot install or authenticate itself." + }, + "found": { "type": ["string", "null"] }, + "required": { "type": "string" }, + "message": { "type": "string" }, + "fix": { "type": ["string", "null"] } + } + }, + "Step": { + "type": "object", + "additionalProperties": false, + "required": ["name", "ok", "message"], + "properties": { + "name": { "type": "string" }, + "ok": { "type": "boolean" }, + "message": { "type": "string" } + } + } + } +} diff --git a/schemas/hive-web-status.v1.json b/schemas/hive-web-status.v1.json new file mode 100644 index 000000000..9183f7d0d --- /dev/null +++ b/schemas/hive-web-status.v1.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ivankuznetsov/hive/blob/main/schemas/hive-web-status.v1.json", + "title": "hive web status output (v1)", + "description": "Stable contract emitted by `hive web status --json`. Reports whether the local managed web service is running, its PID and uptime, the autostart unit state, and the resolved app dir / bind / port. `--json` always exits 0 (emitting `ok: true` with `running: false` when not running); only bare `status` (no `--json`) exits 1 when not running.", + "oneOf": [ + { "$ref": "#/$defs/SuccessPayload" } + ], + "$defs": { + "SuccessPayload": { + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "ok", + "running", + "pid", + "uptime_sec", + "pid_file", + "service_installed", + "service_enabled", + "unit_path", + "app_dir", + "bind", + "port" + ], + "properties": { + "schema": { "const": "hive-web-status" }, + "schema_version": { "const": 1 }, + "ok": { "const": true }, + "running": { + "type": "boolean", + "description": "Whether a live, ownership-verified web server process holds the PID file." + }, + "pid": { + "type": ["integer", "null"], + "description": "PID of the running web server, or null when running=false." + }, + "uptime_sec": { + "type": ["integer", "null"], + "description": "Seconds since the PID file was written (mtime), or null when running=false." + }, + "pid_file": { + "type": "string", + "description": "Absolute path of the PID file the web server writes to." + }, + "service_installed": { + "type": ["boolean", "null"], + "description": "Whether the per-user autostart unit file exists on disk (non-mutating probe). Always present; null only if the probe itself could not run." + }, + "service_enabled": { + "type": ["boolean", "null"], + "description": "Whether the service manager reports the autostart unit as enabled/loaded (non-mutating probe). Always present; null only if the probe itself could not run." + }, + "unit_path": { + "type": ["string", "null"], + "description": "Absolute path of the autostart unit file; null on unsupported platforms or if the probe could not run." + }, + "app_dir": { + "type": ["string", "null"], + "description": "The Rails app dir the web service resolves (env override, managed ~/.local/share/hive/web, or source checkout), or the managed dir fallback when not yet provisioned." + }, + "bind": { + "type": "string", + "description": "Resolved bind address for the web server (default 127.0.0.1)." + }, + "port": { + "type": "integer", + "description": "Resolved port for the web server (default 4567)." + } + } + } + } +} diff --git a/test/e2e/scenarios/local_web_setup.yml b/test/e2e/scenarios/local_web_setup.yml new file mode 100644 index 000000000..02ce282c1 --- /dev/null +++ b/test/e2e/scenarios/local_web_setup.yml @@ -0,0 +1,151 @@ +name: local_web_setup +description: >- + End-to-end acceptance for the first-class local (non-Docker) web + install/run mode (R13/U8): a single `hive setup --json` — with the + external agent CLIs and Hive-owned dep bootstrappers stubbed on PATH and + a hermetic deep-health server on the loopback port — brings up the full + surface (deps → qmd → web bundle → daemon service → project enrollment → + web service + health probe) and persists the loopback no-auth contract. + A task created afterwards is visible in the shared `hive status` source + of truth that both the TUI and web UI read. +tags: [local, web, daemon, setup] +steps: + - kind: ruby_block + block: | + require "socket" + require "open3" + require "rbconfig" + + # Stub the diagnose-only agent CLIs and the Hive-owned dep + # bootstrappers (qmd npm / web bundle + rails) on PATH so `hive setup` + # runs hermetically: version-probes pass, bootstraps no-op, no network. + stubs = File.join(sandbox, "stubs") + FileUtils.mkdir_p(stubs) + stub = lambda do |name, body| + path = File.join(stubs, name) + File.write(path, body) + File.chmod(0o755, path) + end + stub.call("claude", "#!/bin/sh\necho '2.1.118 (Claude Code)'\n") + stub.call("codex", "#!/bin/sh\necho '0.125.0'\n") + stub.call("gh", "#!/bin/sh\nif [ \"$1\" = \"auth\" ]; then exit 0; fi; echo 'gh version 2.66.1'\n") + stub.call("node", "#!/bin/sh\necho 'v18.20.4'\n") + stub.call("npm", "#!/bin/sh\necho '10.8.2'\n") + stub.call("sqlite3", "#!/bin/sh\necho '3.45.3'\n") + stub.call("tmux", "#!/bin/sh\necho 'tmux 3.5a'\n") + stub.call("bundle", "#!/bin/sh\nexit 0\n") + + # A stub Rails app dir so the web bundle resolver (U4) never needs to + # fetch a release; `bin/rails` no-ops db:prepare. + webapp = File.join(sandbox, "webapp") + FileUtils.mkdir_p(File.join(webapp, "config")) + File.write(File.join(webapp, "config", "application.rb"), "# stub rails app marker\n") + FileUtils.mkdir_p(File.join(webapp, "bin")) + rails = File.join(webapp, "bin", "rails") + File.write(rails, "#!/bin/sh\nexit 0\n") + File.chmod(0o755, rails) + + # Hermetic deep-health server on the loopback port so `hive setup`'s + # web health probe succeeds without a real Rails boot. + server = TCPServer.new("127.0.0.1", 4567) + health_thread = Thread.new do + loop do + client = begin + server.accept + rescue IOError, Errno::EBADF + break # socket closed by the ensure block below + end + begin + client.gets("\r\n\r\n") + body = "{\"ok\":true,\"deep\":true}" + client.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" \ + "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}") + rescue StandardError + nil + ensure + client.close rescue nil + end + end + end + + begin + env = { + "HOME" => run_home, + "HIVE_HOME" => run_home, + "HIVEBOX_WEB_APP_DIR" => webapp, + # Prepend the repo's own bin/ so `which("hive")` (used by the daemon + # consistency probe) resolves to THIS checkout's binary, never a + # globally-installed hive on the host PATH — otherwise the installed + # unit (written from $PROGRAM_NAME) and the probe's "expected" binary + # (resolved from PATH) can disagree and report a false drift. + "PATH" => [ File.dirname(Hive::E2E::Paths.hive_bin), stubs, File.dirname(RbConfig.ruby), ENV["PATH"] ].join(File::PATH_SEPARATOR) + } + out, err, status = Open3.capture3(env, RbConfig.ruby, "-I#{Paths.lib_dir}", + Paths.hive_bin, "setup", "--json", chdir: sandbox) + unless status.success? + raise "hive setup --json failed (exit #{status.exitstatus}):\n#{out}\n#{err}" + end + + doc = JSON.parse(out) + unless doc["ok"] == true + raise "hive setup --json reported ok=false:\n#{JSON.pretty_generate(doc)}" + end + steps = doc["steps"].to_h { |s| [ s["name"], s["ok"] ] } + %w[deps qmd web_bundle daemon enroll web].each do |name| + raise "missing/failed setup step #{name}: #{steps.inspect}" unless steps[name] == true + end + unless doc["web_url"] == "http://127.0.0.1:4567" + raise "unexpected web_url #{doc['web_url'].inspect}" + end + + File.write(File.join(sandbox, "setup-envelope.json"), out) + ensure + server.close rescue nil + health_thread.kill + end + + - kind: state_assert + path: "{sandbox}/setup-envelope.json" + + # `hive setup` must persist the local loopback contract (U1/R4): a fresh + # install is not left gated behind the GitHub owner flow. + - kind: state_assert + path: "{run_home}/config.yml" + contains: "auth_mode: loopback" + + # Project enrollment: `hive setup` enables the project for the daemon. + - kind: state_assert + path: "{sandbox}/.hive-state/config.yml" + contains: "enabled: true" + + # The loopback web service status surface reflects the resolved bind/port. + - kind: json_assert + args: [web, status, --json] + schema: hive-web-status + pick: [bind] + equals: "127.0.0.1" + - kind: json_assert + args: [web, status, --json] + schema: hive-web-status + pick: [port] + equals: 4567 + + # `hive daemon status --json` exits 1 when no daemon is running (scriptable + # precondition probe) but still emits the full envelope; after `hive setup` + # the installed unit matches the CLI binary so `consistent` is true. + - kind: json_assert + args: [daemon, status, --json] + expect_exit: 1 + schema: hive-daemon-status + pick: [consistent] + equals: true + + # R13 shared source of truth: a task created through the CLI shows up in + # the same `hive status` document the TUI and web UI both read. + - kind: cli + args: [new, "{project}", "local web round-trip task"] + - kind: json_assert + args: [status, --json] + schema: hive-status + pick: [projects, 0, tasks, 0, stage] + equals: 1-inbox diff --git a/test/integration/daemon_status_consistency_test.rb b/test/integration/daemon_status_consistency_test.rb new file mode 100644 index 000000000..7ba308d26 --- /dev/null +++ b/test/integration/daemon_status_consistency_test.rb @@ -0,0 +1,46 @@ +require "test_helper" +require "json" +require "open3" +require "tmpdir" + +# Integration test for the U5 binary-consistency fields on +# `hive daemon status --json`. Uses real bin/hive subprocesses against an +# isolated HIVE_HOME/HOME so the unit-file probe never touches the real user. +class DaemonStatusConsistencyTest < Minitest::Test + include HiveTestHelper + + REPO_ROOT = File.expand_path("../..", __dir__) + HIVE_BIN = File.join(REPO_ROOT, "bin", "hive") + + def status_json(env) + out, _err, = Open3.capture3(env, "ruby", "-Ilib", HIVE_BIN, "daemon", "status", "--json") + JSON.parse(out) + end + + def test_status_json_includes_consistency_fields_when_consistent + Dir.mktmpdir("hive-consistency") do |home| + env = ENV.to_h.merge("HIVE_HOME" => home, "HOME" => home) + doc = status_json(env) + + assert_kind_of String, doc["binary"], "binary must be the resolved CLI binary" + assert_equal Hive::VERSION, doc["binary_version"] + assert_equal true, doc["consistent"], "no unit + no running daemon = consistent" + end + end + + def test_drifted_unit_surfaces_consistent_false + Dir.mktmpdir("hive-consistency") do |home| + env = ENV.to_h.merge("HIVE_HOME" => home, "HOME" => home) + unit = File.join(home, ".config", "systemd", "user", "hive-daemon.service") + FileUtils.mkdir_p(File.dirname(unit)) + # A stale Apache-Hive / different-binary ExecStart must drift. + File.write(unit, "ExecStart=/usr/bin/hive daemon start\n") + + doc = status_json(env) + assert_equal false, doc["consistent"], + "a unit whose ExecStart binary differs from the resolved CLI binary must be inconsistent" + refute_equal "/usr/bin/hive", doc["binary"], + "the resolved binary must NOT be the stale /usr/bin/hive (Apache Hive shadowing)" + end + end +end diff --git a/test/integration/setup_test.rb b/test/integration/setup_test.rb new file mode 100644 index 000000000..959a98ff9 --- /dev/null +++ b/test/integration/setup_test.rb @@ -0,0 +1,70 @@ +require "test_helper" +require "hive/commands/setup/deps" + +# Integration test for `hive setup`'s dependency checker against REAL +# subprocesses: stub the external agent CLIs on PATH and assert they are +# probed (never installed or authenticated) and that a real `hive setup`'s +# deps step reports them accurately. The full provisioning pipeline (web +# bundle fetch + daemon/web service) is exercised by the e2e scenario (U8); +# this test pins the diagnose-only contract cheaply. +class SetupIntegrationTest < Minitest::Test + include HiveTestHelper + + def stub_bin(dir, name, body) + path = File.join(dir, name) + File.write(path, body) + FileUtils.chmod(0755, path) + path + end + + def with_stubbed_path + with_tmp_dir do |bin| + stub_bin(bin, "claude", "#!/bin/sh\necho '2.1.118 (Claude Code)'\n") + stub_bin(bin, "codex", "#!/bin/sh\necho '0.125.0'\n") + stub_bin(bin, "gh", "#!/bin/sh\nif [ \"$1\" = \"auth\" ]; then exit 0; fi; echo 'gh version 2.66.1'\n") + stub_bin(bin, "node", "#!/bin/sh\necho 'v18.20.4'\n") + stub_bin(bin, "npm", "#!/bin/sh\necho '10.8.2'\n") + stub_bin(bin, "sqlite3", "#!/bin/sh\necho '3.45.3'\n") + stub_bin(bin, "tmux", "#!/bin/sh\necho 'tmux 3.5a'\n") + with_env("PATH" => [ bin, ENV.fetch("PATH", "") ].join(File::PATH_SEPARATOR)) do + yield bin + end + end + end + + def test_deps_probe_real_stub_binaries_without_modifying_them + with_stubbed_path do |bin| + before = Dir.children(bin).to_h { |name| [ name, File.read(File.join(bin, name)) ] } + + results = Hive::Commands::Setup::Deps.new.check + by_name = results.to_h { |r| [ r.name, r ] } + + assert by_name["claude"].ok, "stubbed claude 2.1.118 must satisfy the minimum" + assert by_name["codex"].ok + assert by_name["gh"].ok, "gh auth status returning 0 must count as authenticated" + assert by_name["node/npm"].ok + assert by_name["sqlite3"].ok + assert by_name["tmux"].ok + + # git / ruby come from the real PATH (this host has both). + assert by_name["git"].ok, "real git must be detected on PATH" + assert by_name["ruby"].ok, "the test host's ruby 3.4 must satisfy the gate" + + after = Dir.children(bin).to_h { |name| [ name, File.read(File.join(bin, name)) ] } + assert_equal before, after, + "the dependency check must be read-only — external agent CLIs are never modified" + end + end + + def test_unauthenticated_gh_is_reported_blocked + with_tmp_dir do |bin| + stub_bin(bin, "gh", "#!/bin/sh\nif [ \"$1\" = \"auth\" ]; then exit 1; fi; echo 'gh version 2.66.1'\n") + with_env("PATH" => [ bin, ENV.fetch("PATH", "") ].join(File::PATH_SEPARATOR)) do + gh = Hive::Commands::Setup::Deps.new.check.find { |r| r.name == "gh" } + refute gh.ok + assert gh.blocked, "an unauthenticated gh is blocked, not fixed by setup" + assert_equal "gh auth login", gh.fix + end + end + end +end diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb index dafa1c291..ab987c98a 100644 --- a/test/unit/cli_test.rb +++ b/test/unit/cli_test.rb @@ -519,7 +519,9 @@ class HiveCliTest < Minitest::Test require "hive/commands/web" captured = [] recorder = Class.new do - define_method(:initialize) { |bind:, port:| captured << { bind: bind, port: port } } + define_method(:initialize) do |subcommand = nil, bind:, port:, force:, json:| + captured << { subcommand: subcommand, bind: bind, port: port, force: force, json: json } + end define_method(:call) { captured << :called } end @@ -533,7 +535,7 @@ class HiveCliTest < Minitest::Test Hive::Commands.const_set(:Web, original) end - assert_equal({ bind: "0.0.0.0", port: 9123 }, captured.first, + assert_equal({ subcommand: nil, bind: "0.0.0.0", port: 9123, force: false, json: false }, captured.first, "the --bind/--port flags must reach the web command") assert_equal :called, captured.last, "hive web must invoke the web command's #call" end diff --git a/test/unit/commands/setup/deps_test.rb b/test/unit/commands/setup/deps_test.rb new file mode 100644 index 000000000..59e28f96d --- /dev/null +++ b/test/unit/commands/setup/deps_test.rb @@ -0,0 +1,113 @@ +require "test_helper" +require "hive/commands/setup/deps" + +class SetupDepsTest < Minitest::Test + include HiveTestHelper + + # A fake `runner` that answers per-argv. `script` is a Hash of + # [argv.join(" ")] => [stdout, ok]. + def fake_runner(script) + ->(argv) { script.fetch(argv.join(" ")) { [ "", true ] } } + end + + def build(script) + Hive::Commands::Setup::Deps.new(runner: fake_runner(script)) + end + + def result_for(results, name) + results.find { |r| r.name == name } + end + + def test_all_present_reports_every_dependency_ok + script = { + "ruby --version" => [ "ruby 3.4.10 (2026-06-30)", true ], + "git --version" => [ "git version 2.39.5", true ], + "tmux -V" => [ "tmux 3.5a", true ], + "gh --version" => [ "gh version 2.66.1", true ], + "gh auth status" => [ "", true ], + "claude --version" => [ "2.1.118 (Claude Code)", true ], + "codex --version" => [ "0.125.0", true ], + "node --version" => [ "v18.20.4", true ], + "npm --version" => [ "10.8.2", true ], + "sqlite3 --version" => [ "3.45.3 2024-04-15", true ] + } + results = build(script).check + assert_equal 8, results.size + assert results.all?(&:ok), "every dep present should be ok: #{results.reject(&:ok).map(&:name)}" + end + + def test_ruby_3_4_is_exact_not_a_floor + script = { "ruby --version" => [ "ruby 3.5.0", true ] } + r = result_for(build(script).check, "ruby") + refute r.ok + assert_match(/3\.4/, r.message) + end + + def test_missing_gh_is_blocked_with_install_and_login_fix + script = { "gh --version" => [ "", false ] } + r = result_for(build(script).check, "gh") + refute r.ok + assert r.blocked, "gh is an external CLI — missing must be blocked, not silently installed" + assert_match(/brew install gh/, r.fix) + assert_match(/gh auth login/, r.fix) + end + + def test_installed_but_unauthenticated_gh_is_blocked + script = { "gh --version" => [ "gh version 2.66.1", true ], "gh auth status" => [ "", false ] } + r = result_for(build(script).check, "gh") + refute r.ok + assert r.blocked + assert_equal "gh auth login", r.fix + end + + def test_claude_below_minimum_is_blocked + script = { "claude --version" => [ "2.1.100", true ] } + r = result_for(build(script).check, "claude") + refute r.ok + assert r.blocked + assert_match(/brew upgrade claude/, r.fix) + end + + def test_codex_missing_is_blocked_with_login_fix + script = { "codex --version" => [ "", false ] } + r = result_for(build(script).check, "codex") + refute r.ok + assert r.blocked + assert_match(/brew install codex/, r.fix) + assert_match(/codex login/, r.fix) + end + + def test_missing_node_blocks_node_npm_check + script = { "node --version" => [ "", false ], "npm --version" => [ "10.8.2", true ] } + r = result_for(build(script).check, "node/npm") + refute r.ok + refute r.blocked, "node is not an external agent CLI" + end + + def test_blocked_names_returns_only_external_clis + script = { + "gh --version" => [ "", false ], + "claude --version" => [ "", false ], + "codex --version" => [ "", false ], + "git --version" => [ "git version 2.39.5", true ], + "tmux -V" => [ "tmux 3.5a", true ], + "ruby --version" => [ "ruby 3.4.10", true ], + "node --version" => [ "v18.20.4", true ], + "npm --version" => [ "10.8.2", true ], + "sqlite3 --version" => [ "3.45.3", true ] + } + assert_equal %w[claude codex gh], build(script).blocked_names.sort + end + + def test_to_h_is_schema_shaped + r = Hive::Commands::Setup::Deps::Result.new( + name: "ruby", ok: true, blocked: false, found: "3.4.10", required: "3.4.x", + message: "ok", fix: nil + ) + assert_equal( + { "name" => "ruby", "ok" => true, "blocked" => false, "found" => "3.4.10", + "required" => "3.4.x", "message" => "ok", "fix" => nil }, + r.to_h + ) + end +end diff --git a/test/unit/commands/setup/web_bundle_test.rb b/test/unit/commands/setup/web_bundle_test.rb new file mode 100644 index 000000000..7adadc727 --- /dev/null +++ b/test/unit/commands/setup/web_bundle_test.rb @@ -0,0 +1,101 @@ +require "test_helper" +require "hive/commands/setup/web_bundle" + +class SetupWebBundleTest < Minitest::Test + include HiveTestHelper + + def make_app(dir) + FileUtils.mkdir_p(File.join(dir, "config")) + File.write(File.join(dir, "config", "application.rb"), "# app marker\n") + FileUtils.mkdir_p(File.join(dir, "bin")) + File.write(File.join(dir, "bin", "rails"), "#!/bin/sh\nexit 0\n") + FileUtils.chmod(0755, File.join(dir, "bin", "rails")) + dir + end + + def test_resolution_order_env_then_managed_then_source + with_tmp_global_config do |home| + env_dir = make_app(File.join(home, "env-web")) + managed = Hive::Paths.managed_web_dir + + with_env("HIVEBOX_WEB_APP_DIR" => env_dir) do + assert_equal env_dir, Hive::Commands::Setup::WebBundle.new.resolve_app_dir, + "HIVEBOX_WEB_APP_DIR must win over managed + source" + end + + # No env override, but a populated managed dir → managed wins. + make_app(managed) + assert_equal managed, Hive::Commands::Setup::WebBundle.new.resolve_app_dir + + # No env, no managed app → the sibling source checkout resolves. + FileUtils.rm_rf(managed) + source = File.expand_path("../../../../web", __dir__) + assert_equal source, Hive::Commands::Setup::WebBundle.new.resolve_app_dir + end + end + + def test_marker_write_read_and_mismatch + with_tmp_dir do |dir| + bundle = Hive::Commands::Setup::WebBundle.new + refute bundle.version_mismatch?(dir), "no marker = no mismatch" + + bundle.write_marker(dir) + assert_equal Hive::VERSION, bundle.marker_version(dir) + refute bundle.version_mismatch?(dir) + + File.write(bundle.marker_path(dir), "0.0.1\n") + assert bundle.version_mismatch?(dir), "a stale marker must mismatch" + end + end + + def test_provision_fetches_when_no_app_resolves + with_tmp_global_config do |home| + calls = [] + fetcher = ->(dir) { calls << [ :fetch, dir ]; make_app(dir); { ok: true, message: "fetched" } } + bundle = Hive::Commands::Setup::WebBundle.new(fetcher: fetcher, runner: ->(_argv) { [ "", true ] }) + with_replaced_singleton_method(bundle, :resolve_app_dir, -> { nil }) do + result = bundle.provision + assert result[:ok], result[:message] + assert_equal 1, calls.size, "no app resolved → must fetch" + assert_equal Hive::Paths.managed_web_dir, calls.first[1] + assert_equal Hive::VERSION, bundle.marker_version(Hive::Paths.managed_web_dir) + end + end + end + + def test_provision_fetch_failure_produces_exact_fix_command + with_tmp_global_config do + fetcher = ->(_dir) { { ok: false, message: "run: git clone --depth 1 --branch v#{Hive::VERSION} …" } } + bundle = Hive::Commands::Setup::WebBundle.new(fetcher: fetcher, runner: ->(_argv) { [ "", true ] }) + with_replaced_singleton_method(bundle, :resolve_app_dir, -> { nil }) do + result = bundle.provision + refute result[:ok] + assert_match(/git clone/, result[:message]) + end + end + end + + def test_stale_managed_bundle_is_refetched + with_tmp_global_config do |home| + managed = Hive::Paths.managed_web_dir + make_app(managed) + File.write(File.join(managed, Hive::Commands::Setup::WebBundle::MARKER_FILENAME), "0.0.1\n") + + calls = [] + fetcher = ->(dir) { calls << [ :fetch, dir ]; { ok: true, message: "fetched" } } + bundle = Hive::Commands::Setup::WebBundle.new(fetcher: fetcher, runner: ->(_argv) { [ "", true ] }) + with_replaced_singleton_method(bundle, :resolve_app_dir, -> { managed }) do + bundle.provision + end + assert_equal 1, calls.size, "a stale managed bundle must be re-fetched" + end + end + + def test_managed_dir_predicate + with_tmp_global_config do + bundle = Hive::Commands::Setup::WebBundle.new + assert bundle.managed_dir?(Hive::Paths.managed_web_dir) + refute bundle.managed_dir?(File.join(Hive::Paths.managed_web_dir, "..", "other")) + end + end +end diff --git a/test/unit/commands/setup_test.rb b/test/unit/commands/setup_test.rb new file mode 100644 index 000000000..f3ecd6682 --- /dev/null +++ b/test/unit/commands/setup_test.rb @@ -0,0 +1,212 @@ +require "test_helper" +require "hive/commands/setup" + +class SetupTest < Minitest::Test + include HiveTestHelper + + def ok_result(name) + Hive::Commands::Setup::Deps::Result.new( + name: name, ok: true, blocked: false, found: "x", required: "any", message: "ok", fix: nil + ) + end + + def blocked_result(name) + Hive::Commands::Setup::Deps::Result.new( + name: name, ok: false, blocked: true, found: nil, required: "any", + message: "missing", fix: "brew install #{name}" + ) + end + + def missing_result(name) + Hive::Commands::Setup::Deps::Result.new( + name: name, ok: false, blocked: false, found: nil, required: "any", + message: "missing", fix: "brew install #{name}" + ) + end + + # Fake deps / web_bundle / runner / health_probe recording calls. + def build_setup(script: {}, dep_results: [ ok_result("ruby") ], health: [ true, "ok" ], json: true, **opts) + calls = [] + runner = ->(argv) do + calls << argv + if argv[1] == "daemon" && argv[2] == "status" + # A canned, consistent daemon status so the orchestrator's status + # step passes without a real daemon. + return [ JSON.generate("ok" => true, "running" => true, "consistent" => true), true ] + end + + script.fetch(argv.join(" ")) { [ "", true ] } + end + web_bundle = Object.new + web_bundle.define_singleton_method(:provision) { calls << [ :provision ]; { ok: true, message: "bundled", app_dir: "/app" } } + deps = Object.new + deps.define_singleton_method(:check) { dep_results } + setup = Hive::Commands::Setup.new( + json: json, + non_interactive: true, + project_path: Dir.pwd, + input: StringIO.new, + output: StringIO.new, + summary_io: StringIO.new, + deps: deps, + web_bundle: web_bundle, + runner: runner, + health_probe: -> { health }, + **opts + ) + [ setup, calls ] + end + + # Setup with a fully controlled daemon-status response (canned JSON + exit + # code) so the `ensure_daemon` status/consistency handling is testable + # without a real daemon. + def build_setup_with_daemon_status(status_json, status_ok) + runner = ->(argv) do + return [ status_json, status_ok ] if argv[1] == "daemon" && argv[2] == "status" + + [ "", true ] + end + web_bundle = Object.new + web_bundle.define_singleton_method(:provision) { { ok: true, message: "bundled", app_dir: "/app" } } + deps = Object.new + deps.define_singleton_method(:check) { [ ok_result("ruby") ] } + Hive::Commands::Setup.new( + json: true, non_interactive: true, project_path: Dir.pwd, + input: StringIO.new, output: StringIO.new, summary_io: StringIO.new, + deps: deps, web_bundle: web_bundle, runner: runner, + health_probe: -> { [ true, "ok" ] } + ) + end + + def test_orchestrates_steps_in_dependency_order + with_tmp_global_config do + setup, calls = build_setup + out, = capture_io { setup.call } + payload = JSON.parse(out) + + step_names = payload.fetch("steps").map { |s| s["name"] } + assert_equal %w[deps qmd web_bundle daemon enroll web], step_names, + "setup must run the steps in dependency order" + + # The runner sees daemon install before status, then init/enroll, then web install. + runner_calls = calls.reject { |c| c == [ :provision ] } + assert_includes runner_calls, [ setup.send(:hive_bin), "daemon", "install", "--force" ] + assert_includes runner_calls, [ setup.send(:hive_bin), "daemon", "status", "--json" ] + assert_includes runner_calls, [ setup.send(:hive_bin), "web", "install" ] + end + end + + def test_envelope_matches_hive_setup_schema_shape + with_tmp_global_config do + setup, = build_setup + out, = capture_io { setup.call } + payload = JSON.parse(out) + assert_equal "hive-setup", payload["schema"] + assert_equal Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"), payload["schema_version"] + assert_equal true, payload["ok"] + assert_equal %w[claude codex], payload["backends"], "non-interactive defaults are claude+codex" + assert_equal "http://127.0.0.1:4567", payload["web_url"] + assert_kind_of Array, payload["deps"] + assert_kind_of Array, payload["steps"] + assert_kind_of Array, payload["warnings"] + end + end + + def test_non_tty_uses_default_backends + with_tmp_global_config do + setup, = build_setup + capture_io { setup.call } + assert_equal %w[claude codex], Hive::Config.load_global_agents + end + end + + def test_setup_persists_local_loopback_web_contract + with_tmp_global_config do + setup, = build_setup + capture_io { setup.call } + cfg = Hive::Config.load_global_web + assert_equal "loopback", cfg["auth_mode"], + "a fresh `hive setup` must persist web.auth_mode=loopback, not the owner default" + assert_equal "127.0.0.1", cfg["bind"] + assert_equal 4567, cfg["port"] + end + end + + def test_daemon_status_exit_1_not_running_is_not_a_hard_failure + with_tmp_global_config do + setup = build_setup_with_daemon_status(JSON.generate("ok" => true, "running" => false, "consistent" => true), false) + result = setup.send(:ensure_daemon) + assert result[:ok], "exit-1 (not running) must not fail the daemon step: #{result[:message]}" + assert_match(/consistent/, result[:message]) + end + end + + def test_daemon_status_inconsistent_is_a_hard_failure + with_tmp_global_config do + setup = build_setup_with_daemon_status(JSON.generate("ok" => true, "running" => true, "consistent" => false), true) + result = setup.send(:ensure_daemon) + refute result[:ok] + assert_match(/inconsistent/, result[:message]) + end + end + + def test_daemon_status_consistent_null_is_unverifiable_not_pass + with_tmp_global_config do + setup = build_setup_with_daemon_status(JSON.generate("ok" => true, "running" => true, "consistent" => nil), true) + result = setup.send(:ensure_daemon) + refute result[:ok], "consistent:null must not be reported as pass" + assert_match(/could not be verified/, result[:message]) + end + end + + def test_blocked_external_cli_is_a_warning_not_a_failure + with_tmp_global_config do + setup, = build_setup(dep_results: [ ok_result("ruby"), blocked_result("claude") ]) + out, = capture_io { setup.call } + payload = JSON.parse(out) + assert_equal true, payload["ok"], "blocked external CLIs must not fail the local bring-up" + assert payload["warnings"].any? { |w| w.include?("claude") } + end + end + + def test_missing_hard_dependency_fails_setup + with_tmp_global_config do + setup, = build_setup(dep_results: [ ok_result("ruby"), missing_result("git") ]) + err = assert_raises(Hive::Error) do + capture_io { setup.call } + end + assert_match(/incomplete/, err.message) + end + end + + def test_failed_step_fails_setup + with_tmp_global_config do + # daemon install fails → step ok:false → overall ok:false. + script = { "#{Hive::InvokedBinary.path || 'hive'} daemon install --force" => [ "", false ] } + setup, = build_setup(script: script) + err = assert_raises(Hive::Error) do + capture_io { setup.call } + end + assert_match(/incomplete/, err.message) + end + end + + def test_enroll_prefers_enable_for_registered_project + with_tmp_global_config do |home| + # Register a project whose path == the setup project_path (Dir.pwd here is + # the repo root; use a path that will match via a tmp project). + with_tmp_dir do |proj| + FileUtils.mkdir_p(File.join(home)) + cfg = { "registered_projects" => [ { "name" => "demo", "path" => proj } ] } + File.write(File.join(home, "config.yml"), cfg.to_yaml) + + setup, calls = build_setup + setup.instance_variable_set(:@project_path, File.expand_path(proj)) + capture_io { setup.call } + bin = setup.send(:hive_bin) + assert_includes calls, [ bin, "daemon", "enable", "demo" ], + "an already-registered project must be enrolled via `hive daemon enable`, not re-init" + end + end + end +end diff --git a/test/unit/commands/web/service_installer_test.rb b/test/unit/commands/web/service_installer_test.rb new file mode 100644 index 000000000..4d79ec0e1 --- /dev/null +++ b/test/unit/commands/web/service_installer_test.rb @@ -0,0 +1,156 @@ +require "test_helper" +require "hive/commands/web/service_installer" + +class WebServiceInstallerTest < Minitest::Test + include HiveTestHelper + + def installer_with(dir, hive:, app_dir:, **opts) + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux-gnu", + home: dir, + binary_path: hive, + systemctl_available: false, + **opts + ).tap do |installer| + installer.define_singleton_method(:resolved_app_dir) { app_dir } + end + end + + def test_linux_writes_systemd_unit_with_resolved_binary_and_app_dir + with_tmp_dir do |dir| + hive = File.join(dir, "bin", "hive") + app_dir = File.join(dir, "share", "hive", "web") + installer = installer_with(dir, hive: hive, app_dir: app_dir) + + installer.install!(autostart: false) + unit = File.join(dir, ".config/systemd/user/hive-web.service") + assert File.exist?(unit) + body = File.read(unit) + assert_includes body, "ExecStart=#{hive} web --bind 127.0.0.1", + "ExecStart must run the web tier in the foreground on the loopback bind" + assert_includes body, "Environment=HIVEBOX_WEB_APP_DIR=#{app_dir}", + "the unit must bake the resolved Rails app dir so the service survives login/reboot" + assert_includes body, "Environment=PATH=", + "minimal PATH must be rewritten so the web tier's bin/hive shebang resolves a Ruby with gem deps" + end + end + + def test_linux_autostart_invokes_enable_when_systemd_available + with_tmp_dir do |dir| + commands = [] + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0755, hive) + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: hive, + systemctl_available: true, + runner: ->(argv) { commands << argv } + ) + + result = installer.install!(autostart: true) + assert_equal :written, result.kind + assert_includes commands, %w[systemctl --user daemon-reload] + assert_includes commands, %w[systemctl --user enable --now hive-web] + end + end + + def test_linux_without_systemd_writes_unit_and_reports_autostart_unavailable + with_tmp_dir do |dir| + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: "/tmp/hive", + systemctl_available: false, + runner: ->(_argv) { false } + ) + + result = installer.install!(autostart: true) + assert_equal :autostart_unavailable, result.kind + assert File.exist?(File.join(dir, ".config/systemd/user/hive-web.service")) + assert installer.messages.any? { |msg| msg.include?("hive web start") }, + "the graceful-degradation message should point at `hive web start`" + end + end + + def test_macos_writes_plist_with_resolved_binary_and_app_dir + with_tmp_dir do |dir| + hive = File.join(dir, ".local", "bin", "hive") + app_dir = File.join(dir, ".local", "share", "hive", "web") + installer = installer_with(dir, hive: hive, app_dir: app_dir) + installer.instance_variable_set(:@host_os, "darwin23") + + installer.install!(autostart: false) + plist = File.join(dir, "Library/LaunchAgents/local.hive-web.plist") + assert File.exist?(plist) + body = File.read(plist) + assert_includes body, "#{hive}" + assert_includes body, "web" + assert_includes body, "--bind" + assert_includes body, "127.0.0.1" + assert_includes body, "#{app_dir}", + "launchd plist must bake HIVEBOX_WEB_APP_DIR as an absolute path" + assert_includes body, "RunAtLoad" + end + end + + def test_drifted_existing_unit_is_not_overwritten_without_force + with_tmp_dir do |dir| + unit = File.join(dir, ".config/systemd/user/hive-web.service") + FileUtils.mkdir_p(File.dirname(unit)) + File.write(unit, "custom\n") + installer = installer_with(dir, hive: File.join(dir, "bin", "hive"), app_dir: "/app") + + result = installer.install!(autostart: false) + assert_equal :drifted, result.kind + assert_equal "custom\n", File.read(unit) + assert installer.messages.any? { |msg| msg.include?("hive web install --force") } + end + end + + def test_force_overwrites_drifted_unit_and_writes_backup + with_tmp_dir do |dir| + unit = File.join(dir, ".config/systemd/user/hive-web.service") + FileUtils.mkdir_p(File.dirname(unit)) + File.write(unit, "previous-stale-content\n") + installer = installer_with(dir, hive: File.join(dir, "bin", "hive"), app_dir: "/app") + + result = installer.install!(autostart: false, force: true) + assert_equal :upgraded, result.kind + backups = Dir["#{unit}.bak-*"] + assert_equal 1, backups.size + assert_equal "previous-stale-content\n", File.read(backups.first) + assert_includes File.read(unit), "ExecStart=" + end + end + + def test_service_state_reports_install_and_enable_state + with_tmp_dir do |dir| + installer = installer_with(dir, hive: File.join(dir, "bin", "hive"), app_dir: "/app") + state = installer.service_state + refute state["service_installed"] + + installer.install!(autostart: false) + state = installer.service_state + assert state["service_installed"] + assert_equal "linux", state["platform"] + assert_equal File.join(dir, ".config/systemd/user/hive-web.service"), state["unit_path"] + end + end + + def test_app_dir_falls_back_to_managed_dir_when_unresolved + with_tmp_global_config do + installer = Hive::Commands::Web::ServiceInstaller.new(host_os: "linux") + # Stub the resolver to simulate a host with no source checkout / env + # override: the managed dir is the correct fallback. + with_replaced_singleton_method(Hive::Commands::Web, :rails_app_dir, -> { nil }) do + assert_equal Hive::Paths.managed_web_dir, installer.resolved_app_dir + end + end + end +end diff --git a/test/unit/config_web_test.rb b/test/unit/config_web_test.rb new file mode 100644 index 000000000..b8550e618 --- /dev/null +++ b/test/unit/config_web_test.rb @@ -0,0 +1,62 @@ +require "test_helper" +require "hive/config" + +# U1 / R4 — persisting the local loopback web contract. `hive setup` and +# `hive web install` write `web.auth_mode: "loopback"` (plus the canonical +# loopback bind/port) into the global config so a fresh local install is +# not left gated behind the GitHub owner flow. +class ConfigWebTest < Minitest::Test + include HiveTestHelper + + def test_write_local_web_loopback_persists_the_loopback_contract + with_tmp_global_config do |home| + Hive::Config.write_local_web_loopback! + + cfg = Hive::Config.load_global_web + assert_equal "loopback", cfg["auth_mode"] + assert_equal "127.0.0.1", cfg["bind"] + assert_equal 4567, cfg["port"] + assert Hive::Config.local_web_no_auth?(cfg), "the persisted block must resolve to loopback no-auth" + + data = YAML.safe_load(File.read(File.join(home, "config.yml"))) + assert_equal "loopback", data.dig("web", "auth_mode") + end + end + + def test_write_global_web_preserves_operator_sibling_keys + with_tmp_global_config do |home| + File.write(File.join(home, "config.yml"), { + "registered_projects" => [], + "web" => { "origin" => "https://box.example.com", "github" => { "client_id" => "custom" } } + }.to_yaml) + + Hive::Config.write_local_web_loopback! + + cfg = Hive::Config.load_global_web + assert_equal "https://box.example.com", cfg["origin"], "operator origin must be preserved" + assert_equal "custom", cfg.dig("github", "client_id"), "operator github.client_id must be preserved" + assert_equal "loopback", cfg["auth_mode"] + end + end + + def test_write_global_web_rejects_pre_existing_non_hash_web_block + with_tmp_global_config do |home| + File.write(File.join(home, "config.yml"), { + "registered_projects" => [], + "web" => "not-a-hash" + }.to_yaml) + + err = assert_raises(Hive::ConfigError) { Hive::Config.write_local_web_loopback! } + assert_match(/web .* must be a Hash/, err.message) + end + end + + def test_write_global_web_validates_the_merged_block + with_tmp_global_config do + err = assert_raises(Hive::ConfigError) do + Hive::Config.write_global_web!("auth_mode" => "not-a-mode") + end + assert_match(/auth_mode/, err.message) + end + end +end diff --git a/test/unit/daemon/consistency_test.rb b/test/unit/daemon/consistency_test.rb new file mode 100644 index 000000000..5c8065881 --- /dev/null +++ b/test/unit/daemon/consistency_test.rb @@ -0,0 +1,138 @@ +require "test_helper" +require "hive/daemon/consistency" + +class DaemonConsistencyTest < Minitest::Test + include HiveTestHelper + + FakeInstaller = Struct.new(:resolved_binary, :target_path, :envelope_platform) + + def build(installer:, pid: nil, exe_reader: nil, version_probe: nil) + Hive::Daemon::Consistency.new( + installer: installer, + pid: pid, + exe_reader: exe_reader, + version_probe: version_probe + ) + end + + def test_matching_binary_with_no_unit_and_no_pid_is_consistent + installer = FakeInstaller.new("/usr/local/bin/hive", "/nonexistent-unit", "linux") + result = build(installer: installer).probe + + assert_equal "/usr/local/bin/hive", result["binary"] + assert_equal Hive::VERSION, result["binary_version"] + assert_nil result["unit_binary"], "no unit file → no unit drift" + assert_nil result["running_binary"] + assert result["consistent"] + end + + def test_stale_execstart_binary_is_detected + with_tmp_dir do |dir| + unit = File.join(dir, "hive-daemon.service") + File.write(unit, "ExecStart=/usr/bin/hive daemon start\n") + installer = FakeInstaller.new("/usr/local/bin/hive", unit, "linux") + + result = build(installer: installer).probe + assert_equal "/usr/bin/hive", result["unit_binary"] + refute result["consistent"], "a stale /usr/bin/hive ExecStart must be detected" + end + end + + def test_stale_running_version_is_detected + installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux") + result = build( + installer: installer, + pid: 123, + exe_reader: ->(_pid) { "/usr/local/bin/hive" }, + version_probe: ->(_bin) { "0.2.0" } + ).probe + + refute result["consistent"] + assert_equal "0.2.0", result["running_version"], "a stale daemon --version must surface" + end + + def test_running_binary_drift_is_detected + installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux") + result = build( + installer: installer, + pid: 123, + exe_reader: ->(_pid) { "/usr/bin/hive" }, + version_probe: ->(_bin) { Hive::VERSION } + ).probe + + refute result["consistent"], "a running daemon from a different binary must be detected" + assert_equal "/usr/bin/hive", result["running_binary"] + end + + def test_matching_running_binary_and_version_is_consistent + installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux") + result = build( + installer: installer, + pid: 123, + exe_reader: ->(_pid) { "/usr/local/bin/hive" }, + version_probe: ->(_bin) { Hive::VERSION } + ).probe + + assert result["consistent"] + end + + def test_ps_fallback_reads_hive_script_argument + with_tmp_dir do |dir| + fake_exe = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(fake_exe)) + File.write(fake_exe, "#!/bin/sh\n") + FileUtils.chmod(0755, fake_exe) + + installer = FakeInstaller.new("/usr/local/bin/hive", nil, "linux") + consistency = build(installer: installer) + # `ps` is not on PATH in minimal CI images — stub Open3.capture2 to + # exercise the fallback parser (script-argument scan, not the first + # token — which is the ruby interpreter for a shebang script). + status = Struct.new(:success?).new(true) + with_replaced_singleton_method(Open3, :capture2, ->(*_args) { [ "ruby #{fake_exe} daemon start\n", status ] }) do + assert_equal fake_exe, consistency.send(:read_exe_via_ps, 123), + "ps fallback must select the hive script argument, not the ruby interpreter" + end + end + end + + def test_read_process_exe_reads_cmdline_not_exe_for_script_daemon + with_tmp_dir do |dir| + script = File.join(dir, "hive") + File.write(script, "#!/usr/bin/env ruby\nsleep 30\n") + FileUtils.chmod(0755, script) + + pid = Process.spawn(script, "daemon", "start") + begin + sleep 0.1 + consistency = build(installer: FakeInstaller.new(script, nil, "linux")) + exe = consistency.send(:read_process_exe, pid) + assert_equal script, exe, + "/proc//cmdline must resolve the hive script, never the ruby interpreter (/exe)" + ensure + Process.kill("KILL", pid) rescue nil + Process.waitpid(pid) rescue nil + end + end + end + + def test_launchd_plist_binary_is_parsed + with_tmp_dir do |dir| + plist = File.join(dir, "local.hive-daemon.plist") + File.write(plist, <<~XML) + + ProgramArguments + + /bin/sh-c + [ -x "$0" ] || exit 0; exec "$0" "$@" + /usr/local/bin/hive + + + XML + installer = FakeInstaller.new("/opt/homebrew/bin/hive", plist, "macos") + result = build(installer: installer).probe + assert_equal "/usr/local/bin/hive", result["unit_binary"] + refute result["consistent"], "a plist binary differing from the resolved brew binary must drift" + end + end +end diff --git a/test/unit/openclaw_skills_test.rb b/test/unit/openclaw_skills_test.rb index eb700e97e..e519b71e9 100644 --- a/test/unit/openclaw_skills_test.rb +++ b/test/unit/openclaw_skills_test.rb @@ -23,7 +23,7 @@ class OpenClawSkillsTest < Minitest::Test assert_equal "hive", metadata.fetch("name") assert_equal CLAWHUB_DESCRIPTION, metadata.fetch("description") - assert_equal "0.1.1", metadata.fetch("version") + assert_equal "0.1.2", metadata.fetch("version") assert_equal true, metadata.fetch("user-invocable") assert_equal HOMEPAGE, openclaw_metadata.fetch("homepage") assert_equal true, openclaw_metadata.fetch("always"), "umbrella skill must remain visible for setup" diff --git a/test/unit/paths_test.rb b/test/unit/paths_test.rb index 80ee2b5be..a5885575b 100644 --- a/test/unit/paths_test.rb +++ b/test/unit/paths_test.rb @@ -51,6 +51,20 @@ class PathsTest < Minitest::Test end end + def test_managed_web_dir_lives_under_data_home + with_xdg_home do |dir| + assert_equal File.join(dir, "data", "hive", "web"), Hive::Paths.managed_web_dir + end + end + + def test_managed_web_dir_collapses_under_hive_home_override + with_tmp_dir do |dir| + with_env("HIVE_HOME" => File.join(dir, "legacy"), "XDG_DATA_HOME" => File.join(dir, "data")) do + assert_equal File.join(dir, "legacy", "web"), Hive::Paths.managed_web_dir + end + end + end + def test_legacy_registry_migrates_once_to_xdg_config with_tmp_dir do |dir| home = File.join(dir, "home") diff --git a/test/unit/schema_files_test.rb b/test/unit/schema_files_test.rb index 2d10237de..879cd977d 100644 --- a/test/unit/schema_files_test.rb +++ b/test/unit/schema_files_test.rb @@ -1475,12 +1475,100 @@ class SchemaFilesTest < Minitest::Test # are required-but-nullable in the schema. producer_required = %w[ schema schema_version ok running pid uptime_sec pid_file log_file - service_installed service_enabled unit_path current_version update_nudge + service_installed service_enabled unit_path binary binary_version + consistent current_version update_nudge ].sort assert_equal producer_required, schema_required, "schema/producer required-key drift in hive-daemon-status.v1.json" end + # ── hive-web-status ───────────────────────────────────────────────────── + + def test_hive_web_status_schema_file_exists_and_is_valid_json + path = Hive::Schemas.schema_path("hive-web-status") + assert File.exist?(path), "schema file missing: #{path}" + + doc = JSON.parse(File.read(path)) + assert_equal "https://json-schema.org/draft/2020-12/schema", doc["$schema"] + assert_equal "hive-web-status", + doc.dig("$defs", "SuccessPayload", "properties", "schema", "const") + assert_equal 1, + doc.dig("$defs", "SuccessPayload", "properties", "schema_version", "const") + end + + def test_hive_web_status_required_keys_match_producer_emission + doc = JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-status"))) + schema_required = doc.dig("$defs", "SuccessPayload", "required").sort + # Mirrors the producer's JSON.generate call in Hive::Commands::Web#status_web_service. + producer_required = %w[ + schema schema_version ok running pid uptime_sec pid_file + service_installed service_enabled unit_path app_dir bind port + ].sort + assert_equal producer_required, schema_required, + "schema/producer required-key drift in hive-web-status.v1.json" + end + + def test_hive_web_status_payload_validates_against_published_schema + schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-web-status")))) + payload = { + "schema" => "hive-web-status", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"), + "ok" => true, + "running" => false, + "pid" => nil, + "uptime_sec" => nil, + "pid_file" => "/tmp/.web.pid", + "service_installed" => true, + "service_enabled" => true, + "unit_path" => "/home/you/.config/systemd/user/hive-web.service", + "app_dir" => "/home/you/.local/share/hive/web", + "bind" => "127.0.0.1", + "port" => 4567 + } + errors = schemer.validate(payload).map { |e| e["error"] } + assert_empty errors, + "hive-web-status success payload must validate (errors: #{errors.inspect})" + end + + # ── hive-setup ────────────────────────────────────────────────────────── + + def test_hive_setup_schema_file_exists_and_is_valid_json + path = Hive::Schemas.schema_path("hive-setup") + assert File.exist?(path), "schema file missing: #{path}" + + doc = JSON.parse(File.read(path)) + assert_equal "https://json-schema.org/draft/2020-12/schema", doc["$schema"] + assert_equal "hive-setup", + doc.dig("$defs", "SuccessPayload", "properties", "schema", "const") + assert_equal 1, + doc.dig("$defs", "SuccessPayload", "properties", "schema_version", "const") + end + + def test_hive_setup_success_payload_validates_against_published_schema + schemer = JSONSchemer.schema(JSON.parse(File.read(Hive::Schemas.schema_path("hive-setup")))) + payload = { + "schema" => "hive-setup", + "schema_version" => Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-setup"), + "ok" => true, + "backends" => [ "claude", "codex" ], + "deps" => [ + { "name" => "ruby", "ok" => true, "blocked" => false, "found" => "3.4.10", + "required" => "3.4.x", "message" => "ok", "fix" => nil }, + { "name" => "claude", "ok" => false, "blocked" => true, "found" => nil, + "required" => ">= 2.1.118", "message" => "missing", "fix" => "brew install claude && claude login" } + ], + "steps" => [ + { "name" => "deps", "ok" => true, "message" => "checked 2 dependencies" }, + { "name" => "web", "ok" => true, "message" => "web health OK" } + ], + "web_url" => "http://127.0.0.1:4567", + "warnings" => [ "claude: brew install claude && claude login" ] + } + errors = schemer.validate(payload).map { |e| e["error"] } + assert_empty errors, + "hive-setup success payload must validate (errors: #{errors.inspect})" + end + # ── hive-daemon-stop ─────────────────────────────────────────────────── def test_hive_daemon_stop_schema_file_exists_and_is_valid_json diff --git a/test/unit/web/config_test.rb b/test/unit/web/config_test.rb index a9ed23895..106a8158b 100644 --- a/test/unit/web/config_test.rb +++ b/test/unit/web/config_test.rb @@ -63,4 +63,40 @@ class WebConfigTest < Minitest::Test def test_blank_web_session_secret_file_is_rejected assert_web_config_error({ "session_secret_file" => " " }, /web\.session_secret_file/) end + + def test_web_auth_mode_defaults_to_owner + with_tmp_global_config do + assert_equal "owner", Hive::Config.load_global_web["auth_mode"] + end + end + + def test_invalid_web_auth_mode_is_rejected + assert_web_config_error({ "auth_mode" => "open" }, /web\.auth_mode/) + end + + def test_non_boolean_unsafe_public_no_auth_is_rejected + assert_web_config_error({ "unsafe_public_no_auth" => "yes" }, /web\.unsafe_public_no_auth/) + end + + def test_local_web_no_auth_truth_table + refute Hive::Config.local_web_no_auth?({ "auth_mode" => "owner" }) + assert Hive::Config.local_web_no_auth?({ "auth_mode" => "loopback" }) + refute Hive::Config.local_web_no_auth?({}) + refute Hive::Config.local_web_no_auth?({ "auth_mode" => nil }) + end + + def test_web_bind_loopback_truth_table + assert Hive::Config.web_bind_loopback?("127.0.0.1") + assert Hive::Config.web_bind_loopback?("127.0.0.0") + assert Hive::Config.web_bind_loopback?("127.255.255.255") + assert Hive::Config.web_bind_loopback?("::1") + assert Hive::Config.web_bind_loopback?("localhost") + assert Hive::Config.web_bind_loopback?("LOCALHOST") + assert Hive::Config.web_bind_loopback?("") + assert Hive::Config.web_bind_loopback?(nil) + + refute Hive::Config.web_bind_loopback?("0.0.0.0") + refute Hive::Config.web_bind_loopback?("192.168.1.10") + refute Hive::Config.web_bind_loopback?("example.com") + end end diff --git a/test/unit/web/web_command_test.rb b/test/unit/web/web_command_test.rb index 11de4ab06..e9a37f680 100644 --- a/test/unit/web/web_command_test.rb +++ b/test/unit/web/web_command_test.rb @@ -48,6 +48,80 @@ class WebCommandTest < Minitest::Test assert_empty err, "an https origin implies a fronting proxy — no warning" end end + + # ── U1: local loopback auth policy guard ─────────────────────────────── + + def test_non_loopback_bind_with_loopback_auth_is_refused + with_tmp_global_config do + command = Hive::Commands::Web.new + cfg = { "auth_mode" => "loopback", "unsafe_public_no_auth" => false } + err = assert_raises(Hive::ConfigError) do + command.send(:refuse_unsafe_public_bind, "0.0.0.0", cfg) + end + assert_match(/refusing to bind/, err.message) + assert_match(/loopback/, err.message) + end + end + + def test_non_loopback_bind_with_owner_auth_is_accepted + with_tmp_global_config do + command = Hive::Commands::Web.new + # "owner" is the default and keeps Docker/hivebox behavior (0.0.0.0). + command.send(:refuse_unsafe_public_bind, "0.0.0.0", { "auth_mode" => "owner", "unsafe_public_no_auth" => false }) + command.send(:refuse_unsafe_public_bind, "0.0.0.0", {}) # missing auth_mode = owner default + end + end + + def test_non_loopback_bind_with_unsafe_flag_is_accepted + with_tmp_global_config do + command = Hive::Commands::Web.new + command.send(:refuse_unsafe_public_bind, "0.0.0.0", { "auth_mode" => "loopback", "unsafe_public_no_auth" => true }) + end + end + + def test_loopback_bind_is_always_accepted + with_tmp_global_config do + command = Hive::Commands::Web.new + command.send(:refuse_unsafe_public_bind, "127.0.0.1", { "auth_mode" => "loopback", "unsafe_public_no_auth" => false }) + command.send(:refuse_unsafe_public_bind, "::1", { "auth_mode" => "loopback", "unsafe_public_no_auth" => false }) + command.send(:refuse_unsafe_public_bind, "localhost", { "auth_mode" => "loopback", "unsafe_public_no_auth" => false }) + end + end + + # ── U2: subcommand routing + status envelope ──────────────────────────── + + def test_unknown_subcommand_raises_usage + err = assert_raises(Hive::InvalidTaskPath) do + Hive::Commands::Web.new("bogus").call + end + assert_match(/unknown subcommand/, err.message) + end + + def test_status_json_emits_hive_web_status_envelope + with_tmp_global_config do + command = Hive::Commands::Web.new("status", json: true) + out, = capture_io { command.call } + payload = JSON.parse(out) + assert_equal "hive-web-status", payload["schema"] + assert_equal Hive::Schemas::SCHEMA_VERSIONS.fetch("hive-web-status"), payload["schema_version"] + assert_equal true, payload["ok"] + assert_equal false, payload["running"] + assert_nil payload["pid"] + assert payload.key?("app_dir"), "status must report the resolved app dir" + assert_equal "127.0.0.1", payload["bind"] + assert_equal 4567, payload["port"] + end + end + + def test_status_bare_text_reports_not_running_and_exits_nonzero + with_tmp_global_config do + command = Hive::Commands::Web.new("status") + err = assert_raises(Hive::Error) do + capture_io { command.call } + end + assert_match(/web not running/, err.message) + end + end # Drive the full "app found" path with a stub Rails app: db:prepare # failure raises typed guidance (never a raw backtrace looping under the # container supervisor), and a passing prepare reaches Kernel.exec with diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb index 2a1e5e28f..6a5afe55e 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -11,7 +11,7 @@ class ApplicationController < ActionController::Base before_action :require_login - helper_method :current_login + helper_method :current_login, :local_auth_mode?, :container_managed? # Hive's typed errors are operator-readable by design ("task not in stage", # "invalid clone URL"). Render them on an error page instead of a blank @@ -49,7 +49,34 @@ class ApplicationController < ActionController::Base session[:github_login] end + # Resolved global web config, memoized per request. The Rails tier reads the + # same resolved config the CLI refuses on (Hive::Config.load_global_web), so + # the loopback no-auth policy cannot drift between startup refusal and + # per-request gating. + def web_config + @web_config ||= Hive::Config.load_global_web + end + + # True when the web tier runs in local loopback no-auth mode + # (`web.auth_mode: loopback`). Drives the auth-disabled banner (U6) and the + # require_login skip (U1). + def local_auth_mode? + Hive::Config.local_web_no_auth?(web_config) + end + + # True when the web tier runs under the hivebox container supervisor (the + # daemon is Supervisor-managed, not a per-user systemd/launchd service). + # The supervisor sets HIVEBOX_SUPERVISOR_PID on its children; local mode + # starts the Rails server directly and leaves it unset. + def container_managed? + ENV["HIVEBOX_SUPERVISOR_PID"].to_i > 0 + end + def require_login + # Loopback no-auth mode: no GitHub owner gate at all. Still allow an + # explicit login (the optional owner path stays usable locally). + return if local_auth_mode? + return redirect_to login_path unless current_login # Sessions must track the CURRENT owner, not the owner at sign-in time: @@ -57,7 +84,7 @@ class ApplicationController < ActionController::Base # old sessions alive with repo-scoped credentials. The dev/test seam # signs in arbitrary logins, so it is exempt only where real GitHub auth # is (local envs). - auth = Hive::Web::GithubAuth.new(config: Hive::Config.load_global_web) + auth = Hive::Web::GithubAuth.new(config: web_config) return if auth.owner?(current_login) return if Rails.env.local? && session[:github_token].blank? diff --git a/web/app/controllers/daemon_controller.rb b/web/app/controllers/daemon_controller.rb new file mode 100644 index 000000000..e9b4da8bc --- /dev/null +++ b/web/app/controllers/daemon_controller.rb @@ -0,0 +1,73 @@ +require "open3" +require "tempfile" +require "hive/invoked_binary" + +# Web repair surface for the daemon (U5). `POST /daemon/repair` runs the +# bounded repair as a lifecycle verb — `hive daemon install --force` (rewrites +# the unit's ExecStart to the resolved binary and restarts) then +# `hive daemon start` — NOT through the dispatch queue, matching how the +# task Drop/Approve mutations are in-process. CSRF-protected and +# owner/loopback-gated like every other mutation route. +class DaemonController < ApplicationController + # The repair command runner seam (mirrors SessionsController.http_client). + # Tests inject a recorder; production uses the default bounded subprocess. + class_attribute :repair_runner, default: nil + + DAEMON_REPAIR_TIMEOUT_SEC = Integer(ENV.fetch("HIVEBOX_DAEMON_REPAIR_TIMEOUT_SEC", 120)) + + def repair + # The repair verb rewrites a per-user systemd/launchd unit and restarts + # it — lifecycle actions that only make sense in local/service-managed + # mode. In the Docker/hivebox path the daemon is Supervisor-managed + # (no per-user unit exists), so `install`/`start` can't repair it; tell + # the operator the real remediation instead of misfiring lifecycle verbs. + if container_managed? + redirect_to root_path, + notice: "The daemon is managed by the container supervisor — restart the container to repair it." + return + end + + bin = Hive::InvokedBinary.path || "hive" + commands = [ + [ bin, "daemon", "install", "--force" ], + [ bin, "daemon", "start" ] + ] + + if self.class.repair_runner + self.class.repair_runner.call(commands) + else + commands.each { |argv| bounded_run(argv) } + end + + redirect_to root_path, notice: "Daemon repair completed" + end + + private + + # Bounded subprocess discipline shared with ReposController#clone! and + # TasksController#bounded_diff: own process group, hard wall-clock deadline, + # output to a tempfile, typed error page on timeout/failure. + def bounded_run(argv) + log = Tempfile.create("hivebox-daemon-repair") + pid = Process.spawn(*argv, pgroup: true, out: log.path, err: log.path) + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + DAEMON_REPAIR_TIMEOUT_SEC + status = nil + loop do + _, status = Process.waitpid2(pid, Process::WNOHANG) + break if status + + if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + Process.kill("KILL", -pid) rescue nil + Process.waitpid2(pid) rescue nil + raise Hive::Error, "daemon repair timed out after #{DAEMON_REPAIR_TIMEOUT_SEC}s" + end + sleep 0.2 + end + return if status.success? + + raise Hive::Error, "daemon repair failed: #{File.read(log.path).strip}" + ensure + log&.close + File.unlink(log.path) if log && File.exist?(log.path) + end +end diff --git a/web/app/controllers/health_controller.rb b/web/app/controllers/health_controller.rb index cda67e408..4cd5f8436 100644 --- a/web/app/controllers/health_controller.rb +++ b/web/app/controllers/health_controller.rb @@ -3,29 +3,20 @@ require "hive/pid_file" class HealthController < ApplicationController skip_before_action :require_login - # Reads the daemon's pidfile the same way `hive daemon status` does — - # stale files and reused PIDs don't count as alive. - class DaemonProbe - include Hive::PidFile - - def pid_file - File.join(Hive::Paths.state_home, ".daemon.pid") - end - end - # `/health` is web liveness (also the supervisor/installer smoke), while # `/health?deep=1` is the container readiness probe: the box is only # useful when the daemon child is running too — a crashlooping daemon # must turn the container unhealthy, not sit invisible behind a green - # web tier. + # web tier. Deep health also carries the U5 binary-consistency fields so + # the readiness gate and the status card read the same data. def show return render json: { ok: true } unless params[:deep].present? - daemon_pid = DaemonProbe.new.read_live_pid - if daemon_pid - render json: { ok: true, daemon: { running: true, pid: daemon_pid } } + daemon = DaemonHealth.new.snapshot + if daemon[:running] + render json: { ok: true, daemon: daemon } else - render json: { ok: false, daemon: { running: false } }, status: :service_unavailable + render json: { ok: false, daemon: daemon }, status: :service_unavailable end end end diff --git a/web/app/controllers/sessions_controller.rb b/web/app/controllers/sessions_controller.rb index f1fecfd8c..769316ee5 100644 --- a/web/app/controllers/sessions_controller.rb +++ b/web/app/controllers/sessions_controller.rb @@ -16,6 +16,12 @@ class SessionsController < ApplicationController def new return redirect_to root_path if current_login + # Loopback no-auth mode: the first-run claim flow is not just broken — it + # is unnecessary. Show an explicit "auth disabled locally" notice instead + # of a device-flow button that can never complete (no owner gate). + @auth_disabled = local_auth_mode? + return if @auth_disabled + # Surface a misconfigured box on the page itself — a sign-in button that # errors only after the click is a broken first-run. @configured = github_auth.configured? diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb index c44440a83..0d673db5c 100644 --- a/web/app/controllers/status_controller.rb +++ b/web/app/controllers/status_controller.rb @@ -2,5 +2,6 @@ class StatusController < ApplicationController def index @payload = StatusBroadcaster.snapshot @projects = @payload.fetch("projects", []) + @daemon = DaemonHealth.new.snapshot end end diff --git a/web/app/models/daemon_health.rb b/web/app/models/daemon_health.rb new file mode 100644 index 000000000..ff4e2db99 --- /dev/null +++ b/web/app/models/daemon_health.rb @@ -0,0 +1,27 @@ +require "hive/pid_file" +require "hive/daemon/consistency" + +# Shared daemon health snapshot for the web tier: reads the daemon's pidfile +# (same liveness + ownership checks as `hive daemon status`) and layers the +# U5 binary-consistency probe on top. Used by the deep health endpoint and the +# status-page daemon card so the two surfaces can never disagree. +class DaemonHealth + include Hive::PidFile + + def pid_file + File.join(Hive::Paths.state_home, ".daemon.pid") + end + + def snapshot + pid = read_live_pid + consistency = Hive::Daemon::Consistency.new(pid: pid).probe + { + running: !pid.nil?, + pid: pid, + binary_version: consistency["binary_version"], + consistent: consistency["consistent"] + } + rescue StandardError + { running: false, pid: nil, binary_version: Hive::VERSION, consistent: nil } + end +end diff --git a/web/app/views/layouts/application.html.erb b/web/app/views/layouts/application.html.erb index 77e0c5f9d..2f6925156 100644 --- a/web/app/views/layouts/application.html.erb +++ b/web/app/views/layouts/application.html.erb @@ -24,6 +24,9 @@
<%= link_to "hivebox", root_path, class: "brand" %> + <% if local_auth_mode? %> + local mode — no auth + <% end %> <% if current_login %>