diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39fa61fba..2ecc77b71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,6 +51,32 @@ jobs: name: hive-cli-gem path: hive-cli-*.gem if-no-files-found: error + - name: Build web app tarball + # U2 — gem installs have no web/ tree (the gem deliberately does + # not package it), so `hive setup` downloads this version-matched + # archive instead. Built in the SAME release job as the gem so the + # two can never drift. Dev/test-only content is stripped; the + # provisioner rewrites the embedded hive-cli path dependency at + # install time. + run: | + version="${GITHUB_REF_NAME#v}" + staging_root="$(mktemp -d)" + staging="$staging_root/hive-web-$version" + mkdir -p "$staging" + rsync -a \ + --exclude node_modules --exclude .bundle --exclude vendor/bundle \ + --exclude tmp/pids --exclude log --exclude storage --exclude coverage \ + --exclude spec --exclude test \ + web/ "$staging/" + tar -czf "hive-web-$version.tar.gz" -C "$staging_root" "hive-web-$version" + sha256sum "hive-web-$version.tar.gz" > "hive-web-$version.tar.gz.sha256" + - uses: actions/upload-artifact@v7 + with: + name: hive-web-tarball + path: | + hive-web-*.tar.gz + hive-web-*.tar.gz.sha256 + if-no-files-found: error install-gate: name: gem-install gate / ${{ matrix.runs-on }} @@ -116,10 +142,14 @@ jobs: with: name: hive-cli-gem path: dist + - uses: actions/download-artifact@v8 + with: + name: hive-web-tarball + path: dist - name: Build checksums run: | cd dist - sha256sum hive-cli-*.gem > SHA256SUMS + sha256sum hive-cli-*.gem hive-web-*.tar.gz > SHA256SUMS - name: Install cosign uses: sigstore/cosign-installer@v3 - name: Sign checksums diff --git a/README.md b/README.md index 2fca31369..807e31bda 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,18 @@ The TUI is the recommended human interface and an agent-driven CLI is the recomm Full per-command reference, every flag, every envelope field, and every exit code lives in [docs/cli.md](docs/cli.md). +## Local Web UI (no Docker) + +`hive setup` brings up the whole local stack on Linux or macOS with one command: dependency preflight (diagnose-only for `claude`/`codex`/`gh` — it prints the exact fix command and never installs or authenticates anything silently), a version-matched Rails web app under XDG data home (downloaded from the matching GitHub release when there is no source checkout), the hive-daemon user service pinned to the same binary as your CLI, project enrollment so new tasks dispatch automatically, and the web UI verified via its deep health check: + +```bash +hive setup # in a project directory; prints http://127.0.0.1:4567 when done +hive setup --all # enroll every registered project instead of just cwd +hive setup --doctor-only # just the dependency report +``` + +The web tier is managed like the daemon: `hive web install|start|stop|status [--json]`. Bare `hive web` still runs the server in the foreground with no service required. On loopback binds (`127.0.0.1`, the default) no sign-in is required; non-loopback binds require the GitHub owner flow or an explicit `--unsafe-public`. Docker/hivebox is unchanged. See [wiki/commands/setup.md](wiki/commands/setup.md) and [wiki/commands/web.md](wiki/commands/web.md). + ## Documentation - **[hivecli.sh](https://hivecli.sh)** — The public website: an outcome-first overview plus curated docs (getting started, concepts, configuration, the user-facing command reference, and operating). Agent-friendly too: every page is available as raw markdown and there's an [`llms.txt`](https://hivecli.sh/llms.txt) index. Start here if you're new. diff --git a/examples/launchd/hive-web.plist b/examples/launchd/hive-web.plist new file mode 100644 index 000000000..06349cdc6 --- /dev/null +++ b/examples/launchd/hive-web.plist @@ -0,0 +1,67 @@ + + + + + + Label + local.hive-web + + ProgramArguments + + /bin/sh + -c + [ -x "$0" ] || exit 0; exec "$0" "$@" + /Users/YOU/.local/bin/hive + web + + + 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 + + + diff --git a/examples/systemd/hive-web.service b/examples/systemd/hive-web.service new file mode 100644 index 000000000..3238b6c6a --- /dev/null +++ b/examples/systemd/hive-web.service @@ -0,0 +1,44 @@ +# Sample systemd-user unit for `hive web` (Linux) — the managed local web +# tier. This is a SEPARATE service from hive-daemon (the daemon and the web +# UI are two independent units by design; never merge them). +# +# `hive web install` writes this file with ExecStart= and HIVE_BIN= rewritten +# to the resolved `hive` binary, then enables + starts it. Manual 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 started (a wrong ExecStart= shows up here as `failed`): +# systemctl --user status hive-web +# curl -fsS http://127.0.0.1:4567/health +# +# View logs: +# journalctl --user -u hive-web -f +# +# Stop / restart: +# systemctl --user stop hive-web (or `hive web stop` / `hive web start`) + +[Unit] +Description=Hive web UI (local mode) +After=default.target +StartLimitBurst=3 +StartLimitIntervalSec=300 + +[Service] +Type=simple +# HIVE_BIN pins the exact hive binary so a PATH change can never silently +# drift the service onto a different install than the CLI that installed it +# (`hive setup` detects and repairs that drift via a --force reinstall). +Environment=HIVE_BIN=%h/.local/bin/hive +Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=%h/.local/bin/hive web +Restart=on-failure +RestartSec=10s +KillMode=mixed +TimeoutStopSec=90 + +[Install] +WantedBy=default.target diff --git a/lib/hive/cli.rb b/lib/hive/cli.rb index cd8090689..5f4feed64 100644 --- a/lib/hive/cli.rb +++ b/lib/hive/cli.rb @@ -1332,10 +1332,102 @@ module Hive ).call end - desc "web", "Run the hivebox web UI" + desc "setup [PROJECT]", "Provision and validate the local stack: deps, web app, daemon service, enrollment, web UI" + long_desc <<~DESC + One command that brings up the local (non-Docker) Hive stack and + verifies it end to end: + + 1. preflight — dependency report (Ruby 3.4, git, tmux, gh, + claude, codex, Node/npm, qmd, bundler, SQLite). + External agent CLIs are diagnose-only: the exact + fix command is printed, nothing is ever installed + or authenticated silently. + 2. web_app — resolve or provision the version-matched Rails + web app under XDG data home. + 3. daemon — ensure the hive-daemon service runs THIS hive + binary; drift is repaired via a --force reinstall. + 4. enroll — leave PROJECT (default: current directory) + enrolled so new tasks dispatch automatically. + Pass --all to enroll every registered project. + 5. web — start the managed web service and verify + GET /health?deep=1, then print the URL + (default http://127.0.0.1:4567). + + Idempotent: re-running skips completed work. Each step can be skipped + with --skip-. `--doctor-only` prints just the dependency + report. Port conflicts are reported with the owning process and FAIL + the run — hive never kills listeners and never silently picks + another port. + + Exit code: 0 when every run step succeeded; 1 otherwise. + DESC + option :json, type: :boolean, default: false, desc: "emit the hive-setup.v1 step envelope on stdout" + option :"doctor-only", type: :boolean, default: false, desc: "run only the dependency report" + option :"skip-preflight", type: :boolean, default: false, desc: "skip the dependency preflight" + option :"skip-web-app", type: :boolean, default: false, desc: "skip web app resolution/provisioning" + option :"skip-daemon", type: :boolean, default: false, desc: "skip the daemon service guard" + option :"skip-enroll", type: :boolean, default: false, desc: "skip project enrollment" + option :"skip-web", type: :boolean, default: false, desc: "skip starting/verifying the web UI" + option :all, type: :boolean, default: false, desc: "enroll every registered project instead of PROJECT" + option :"unsafe-public", type: :boolean, default: false, hide: true + def setup(project = nil) + require "hive/commands/setup" + ok = Hive::Commands::Setup.new( + project: project, + json: options[:json], + doctor_only: options[:"doctor-only"], + skip_preflight: options["skip-preflight"], + skip_web_app: options["skip-web-app"], + skip_daemon: options["skip-daemon"], + skip_enroll: options["skip-enroll"], + skip_web: options["skip-web"], + enroll_all: options[:all], + unsafe_public: options["unsafe-public"] + ).call + exit 1 unless ok + end + + desc "web SUBCOMMAND", "Run the hivebox web UI (bare / run: foreground; install / start / stop / status: managed service)" option :bind, type: :string, desc: "override web.bind" option :port, type: :numeric, desc: "override web.port" - def web + option :unsafe_public, type: :boolean, default: false, + desc: "allow a non-loopback bind with auth mode none (explicit opt-in)" + option :force, type: :boolean, default: false, + desc: "for install: overwrite an existing unit (saves .bak)" + def web(subcommand = nil, *extra) + unless extra.empty? + raise Hive::InvalidTaskPath, + "hive web: unexpected arguments #{extra.inspect}; " \ + "usage: hive web [install|start|stop|status|run]" + end + + case subcommand + when nil, "run" + web_foreground + when "install" + require "hive/commands/web" + Hive::Commands::Web.new.install_service!(force: options[:force], json: options[:json]) + when "start" + require "hive/commands/web" + Hive::Commands::Web.new.start_service!(json: options[:json]) + when "stop" + require "hive/commands/web" + Hive::Commands::Web.new.stop_service!(json: options[:json]) + when "status" + require "hive/commands/web" + Hive::Commands::Web.new(bind: options[:bind], port: options[:port]) + .service_status(json: options[:json]) + else + raise Hive::InvalidTaskPath, + "hive web: unknown subcommand #{subcommand.inspect} " \ + "(expected: install, start, stop, status, or bare/run for foreground)" + end + end + + # Thor exposes public instance methods as commands — keep the + # foreground helper out of that surface entirely. + no_commands do + def web_foreground if options[:json] require "json" message = "hive web has no JSON output (it runs a long-lived server). " \ @@ -1355,7 +1447,9 @@ module Hive end require "hive/commands/web" - Hive::Commands::Web.new(bind: options[:bind], port: options[:port]).call + Hive::Commands::Web.new(bind: options[:bind], port: options[:port], + unsafe_public: options[:unsafe_public]).call + end end desc "tui", "Open the live, keystroke-driven dashboard for every active task" diff --git a/lib/hive/commands/setup.rb b/lib/hive/commands/setup.rb new file mode 100644 index 000000000..eea91eff9 --- /dev/null +++ b/lib/hive/commands/setup.rb @@ -0,0 +1,398 @@ +require "fileutils" +require "json" +require "net/http" +require "socket" +require "yaml" + +require "hive/config" +require "hive/paths" +require "hive/commands/web" + +module Hive + module Commands + # `hive setup` — one command that provisions and validates the whole + # local (non-Docker) stack (U7), composing U2–U6: + # + # 1. preflight — dependency report (U3); diagnose-only for external + # agent CLIs, hard-fails only on Ruby/git/bundle + # 2. web_app — resolve or provision the version-matched Rails app + # (U2) + # 3. daemon — ensure the daemon service runs THIS CLI binary + # (U5 guard; repair = daemon installer --force) + # 4. enroll — leave the target project enrolled + # (daemon.enabled: true) so tasks dispatch (U6) + # 5. web — start the managed web service (or detached + # fallback) and verify GET /health?deep=1, then print + # the URL (U4 + health probe) + # + # Idempotent: re-runs no-op completed steps. Every step is skippable + # (--skip-*) and reported in the hive-setup.v1 envelope. Port handling + # is truthful: if 4567 is already owned by another process we report + # the listener and FAIL the step — never kill, never silently pick an + # alternate port (the printed URL must be real). + class Setup + HEALTH_TIMEOUT_SEC = 30 + + StepResult = Struct.new(:name, :status, :detail, :fix, keyword_init: true) + + attr_reader :steps + + def initialize(project: nil, json: false, doctor_only: false, + skip_preflight: false, skip_web_app: false, skip_daemon: false, + skip_enroll: false, skip_web: false, enroll_all: false, + unsafe_public: false, + preflight: nil, guard: nil, provisioner: nil) + @project = project + @json = json + @doctor_only = doctor_only + @skip = { + "preflight" => skip_preflight, + "web_app" => skip_web_app, + "daemon" => skip_daemon, + "enroll" => skip_enroll || enroll_all, + "web" => skip_web + } + @enroll_all = enroll_all + @unsafe_public = unsafe_public + @preflight = preflight + @guard = guard + @provisioner = provisioner + @steps = [] + end + + def call + return doctor_only_run if @doctor_only + + run_step("preflight") { step_preflight } + run_step("web_app") { step_web_app } + run_step("daemon") { step_daemon } + run_step("enroll") { step_enroll } + run_step("web") { step_web } + + url = @steps.find { |s| s.name == "web" }&.status == "ok" ? web_url : nil + if @json + require "json" + puts JSON.generate(envelope(url)) + else + render_summary(url) + end + @steps.none? { |s| s.status == "failed" } + end + + def failed_steps + @steps.select { |s| s.status == "failed" } + end + + private + + def run_step(name) + if @skip[name] + @steps << StepResult.new(name: name, status: "skipped", detail: nil, fix: nil) + return + end + + result = yield + @steps << result + rescue StandardError => e + @steps << StepResult.new(name: name, status: "failed", detail: "#{e.class}: #{e.message}", fix: nil) + end + + # ── U3 ───────────────────────────────────────────────────────── + def step_preflight + report = (@preflight ||= build_preflight) + capture_stdout { report.call } + hard = report.hard_failures + warnings = report.warnings + rows_by_name = report.rows.group_by(&:name) + + if hard.any? + detail = "missing/too old: #{hard.join(', ')}" + fixes = hard.map { |name| (rows_by_name[name].first&.fix || "").to_s }.compact.reject(&:empty?) + return StepResult.new( + name: "preflight", status: "failed", detail: detail, + fix: fixes.any? ? fixes.join("; ") : nil + ) + end + + detail = warnings.any? ? "warnings: #{warnings.join(', ')} (fix commands above)" : "all dependencies present" + StepResult.new(name: "preflight", status: "ok", detail: detail, fix: nil) + end + + def build_preflight + require "hive/setup/preflight" + Hive::Setup::Preflight.new + end + + # ── U2 ───────────────────────────────────────────────────────── + def step_web_app + cfg = Hive::Config.load_global_web + app_dir = web_app_dir || provision(cfg) + unless app_dir + return StepResult.new( + name: "web_app", status: "failed", + detail: "no Rails web app found and provisioning failed", + fix: "point HIVEBOX_WEB_APP_DIR at a web/ checkout matching this hive version" + ) + end + + StepResult.new(name: "web_app", status: "ok", detail: "app at #{app_dir}", fix: nil) + end + + def web_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")) } + end + + def provision(_cfg) + (@provisioner ||= build_provisioner).provision + rescue Hive::Error => e + warn "hive setup: web app provisioning failed: #{e.message}" + nil + end + + def build_provisioner + require "hive/web/app_provisioner" + Hive::Web::AppProvisioner.new + end + + # ── U5 ───────────────────────────────────────────────────────── + def step_daemon + require "hive/setup/daemon_guard" + guard = (@guard ||= Hive::Setup::DaemonGuard.new) + result = guard.check + + case result.status + when "unsupported" + StepResult.new(name: "daemon", status: "manual", detail: result.detail, + fix: "run `hive daemon start` manually") + when "not_installed", "drifted" + outcome = capture_stdout { guard.repair! } + unless outcome.success? + return StepResult.new(name: "daemon", status: "failed", + detail: "daemon service install failed (#{outcome.wire_outcome})", + fix: "run `hive daemon install --force` manually") + end + post = guard.check + StepResult.new( + name: "daemon", status: post.status == "ok" ? "ok" : "failed", + detail: post.status == "ok" ? + "daemon unit pinned to #{post.service_binary} (was #{result.status}; repaired)" : + "repair ran but guard still reports #{post.status}", + fix: post.status == "ok" ? nil : "run `hive daemon install --force` manually" + ) + else + detail = result.running ? "running (pid #{result.pid}), binary matches" : "binary matches; not currently running" + StepResult.new(name: "daemon", status: "ok", detail: detail, fix: nil) + end + end + + # ── U6 ───────────────────────────────────────────────────────── + def step_enroll + if @enroll_all + require "hive/commands/daemon" + capture_stdout { Hive::Commands::Daemon.new("enable", nil, all: true, json: false).call } + return StepResult.new(name: "enroll", status: "ok", detail: "all registered projects enabled", fix: nil) + end + + dir = File.expand_path(@project || ".") + entry = Hive::Config.project_for_path(dir) + if entry.nil? + require "hive/commands/init" + capture_stdout { Hive::Commands::Init.new(dir).call } + return StepResult.new(name: "enroll", status: "ok", detail: "initialised #{dir} (daemon enabled by default)", fix: nil) + end + + cfg_path = File.join(entry.fetch("hive_state_path"), "config.yml") + enabled = project_daemon_enabled?(cfg_path) + if enabled == true + StepResult.new(name: "enroll", status: "ok", detail: "#{entry['name']} already enrolled", fix: nil) + elsif enabled.nil? + StepResult.new(name: "enroll", status: "failed", detail: "#{cfg_path} missing or unreadable", + fix: "run `hive init #{dir}` to repair the project config") + else + require "hive/commands/daemon" + capture_stdout { Hive::Commands::Daemon.new("enable", entry.fetch("name"), json: false).call } + StepResult.new(name: "enroll", status: "ok", detail: "#{entry['name']} enrolled (daemon.enabled → true)", fix: nil) + end + end + + def project_daemon_enabled?(cfg_path) + return nil unless File.file?(cfg_path) + + data = YAML.safe_load_file(cfg_path) rescue nil + return nil unless data.is_a?(Hash) + + data.dig("daemon", "enabled") + end + + # ── Web launch + verification ────────────────────────────────── + def step_web + cfg = Hive::Config.load_global_web + bind = cfg.fetch("bind") + port = cfg.fetch("port").to_i + + # Already-healthy web (our own service or a previous setup run) is + # a no-op — and must be checked BEFORE the port-conflict probe, + # since a running web legitimately owns the port. + if web_already_up?(bind, port) + detail = "already running" + else + owner = port_listener_pid(bind, port) + if owner + return StepResult.new( + name: "web", status: "failed", + detail: "port #{port} is already taken by pid #{owner[:pid]} (#{owner[:command]})", + fix: "stop that process or set web.port in the global config; hive never kills listeners and never silently moves ports" + ) + end + + require "hive/commands/web" + capture_stdout { Hive::Commands::Web.new.start_service!(json: false) } + detail = "started" + end + + unless deep_health_ok?(bind, port) + return StepResult.new( + name: "web", status: "failed", + detail: "web did not become healthy at http://#{bind}:#{port}/health within #{HEALTH_TIMEOUT_SEC}s", + fix: "run `hive web` in the foreground to see the boot error" + ) + end + + StepResult.new(name: "web", status: "ok", detail: "#{detail}; healthy at #{web_url}", fix: nil) + end + + def web_already_up?(bind, port) + require "hive/commands/web" + return true if Hive::Commands::Web.new.read_live_pid + + quick_health_ok?(bind, port) + end + + def web_url + cfg = Hive::Config.load_global_web + "http://#{cfg.fetch('bind')}:#{cfg.fetch('port')}" + end + + # Who owns the port? Best-effort: try binding ourselves first (the + # common "free" case must not shell out); when occupied, ask lsof/ss + # where available. Never kills anything. + def port_listener_pid(bind, port) + begin + server = TCPServer.new(bind == "0.0.0.0" ? "0.0.0.0" : bind, port) + server.close + return nil + rescue Errno::EADDRINUSE + # fall through to identification + end + + identify_listener(port) + end + + def identify_listener(port) + %W[lsof -iTCP:#{port} -sTCP:LISTEN -P -n].then do |argv| + out = IO.popen(argv, &:read) rescue "" + if (match = out.match(/\S+\s+(\d+)\s+\S+\s+\S+\s*(.*)/)) + pid = match[1] + command_line = begin + File.read("/proc/#{pid}/cmdline").tr("\0", " ").strip + rescue StandardError + match[2].to_s.strip + end + return { pid: pid, command: command_line.empty? ? "unknown" : command_line } + end + end + { pid: "unknown", command: "unknown listener" } + end + + def deep_health_ok?(bind, port, timeout: HEALTH_TIMEOUT_SEC) + host = Hive::Commands::Web::LOOPBACK_BINDS.include?(bind) ? "127.0.0.1" : bind + deadline = Time.now + timeout + while Time.now < deadline + return true if one_health_probe_ok?(host, port) + + sleep 0.5 + end + false + end + + def quick_health_ok?(bind, port) + host = Hive::Commands::Web::LOOPBACK_BINDS.include?(bind) ? "127.0.0.1" : bind + one_health_probe_ok?(host, port) + end + + def one_health_probe_ok?(host, port) + response = Net::HTTP.start(host, port, open_timeout: 2, read_timeout: 5) do |http| + http.get("/health?deep=1") + end + response.is_a?(Net::HTTPSuccess) + rescue StandardError + false + end + + # ── Reporting ────────────────────────────────────────────────── + def doctor_only_run + report = (@preflight ||= build_preflight) + capture_stdout { report.call } + ok = report.hard_failures.empty? + puts JSON.generate("schema" => "hive-setup-doctor", + "schema_version" => 1, + "ok" => ok, + "rows" => report.rows.map do |row| + { "name" => row.name, "status" => row.status, + "message" => row.message, "fix" => row.fix, + "required" => row.required } + end) if @json + unless @json + report.render + if ok + puts "hive setup: preflight passed" + else + puts "hive setup: hard dependency failures — fix the rows marked missing/version_too_old above" + end + end + ok + end + + def envelope(url) + { + "schema" => "hive-setup", + "schema_version" => 1, + "ok" => failed_steps.empty?, + "url" => url, + "steps" => @steps.map do |step| + { "name" => step.name, "status" => step.status, + "detail" => step.detail, "fix" => step.fix } + end + }.compact + end + + def render_summary(url) + puts "hive setup:" + @steps.each do |step| + icon = { "ok" => "✔", "failed" => "✘", "manual" => "!", "skipped" => "·" }.fetch(step.status, "·") + line = " #{icon} #{step.name}: #{step.status}" + line += " — #{step.detail}" if step.detail + puts line + puts " fix: #{step.fix}" if step.fix + end + if url && failed_steps.empty? + puts "" + puts " hive web is live at #{url}" + end + end + + def capture_stdout + require "stringio" + original = $stdout + $stdout = StringIO.new + yield + ensure + $stdout = original + end + end + end +end diff --git a/lib/hive/commands/web.rb b/lib/hive/commands/web.rb index eb3cd40fc..cc3de4c92 100644 --- a/lib/hive/commands/web.rb +++ b/lib/hive/commands/web.rb @@ -1,4 +1,8 @@ +require "socket" + require "hive/config" +require "hive/invoked_binary" +require "hive/pid_file" require "hive/web/session_secret" module Hive @@ -6,18 +10,29 @@ module Hive # 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. + # bundle exist: the hivebox container, a source checkout, or a + # provisioned local install (see Hive::Web::AppProvisioner). class Web - def initialize(bind: nil, port: nil) + include Hive::PidFile + + # Binds that only expose the UI to processes on the same host. A + # loopback bind with effective auth `none` is the zero-config local + # mode: no GitHub sign-in gate. + LOOPBACK_BINDS = %w[127.0.0.1 localhost ::1].freeze + + def initialize(bind: nil, port: nil, unsafe_public: false) @bind = bind @port = port + @unsafe_public = unsafe_public + @hive_home = Hive::Paths.state_home end def call cfg = Hive::Config.load_global_web bind = @bind || cfg.fetch("bind") port = (@port || cfg.fetch("port")).to_i - app_dir = rails_app_dir + auth_mode = resolve_auth_mode!(bind, cfg) + app_dir = rails_app_dir || provision_app_dir!(cfg) unless app_dir warn "hive web: the hivebox web app (web/) was not found. " \ "Run from the hivebox Docker image or a source checkout, " \ @@ -35,6 +50,10 @@ module Hive "SECRET_KEY_BASE" => ENV["SECRET_KEY_BASE"] || Hive::Web::SessionSecret.load_or_create(cfg.fetch("session_secret_file")), "HIVEBOX_ORIGIN" => cfg.fetch("origin"), + # Effective auth decision made here (loopback no-auth vs GitHub + # owner gate) so the Rails require_login filter can honor it + # without re-deriving the bind address. + "HIVEBOX_AUTH_MODE" => auth_mode, # The solid_cable/cache/queue sqlite files must survive image # upgrades — keep them in state_home (on /data in the container), # not in the app dir. @@ -62,8 +81,212 @@ module Hive end end + # ── Managed lifecycle (U4) ────────────────────────────────── + # The web tier gets the same per-user service contract as the + # daemon: `install [--force]` writes + enables a systemd-user/ + # launchd unit; `start` / `stop` drive it (falling back to a + # detached foreground process with a pidfile where no user service + # manager exists); `status [--json]` reports without mutating. + # Foreground `call` above never touches any of this. + + def pid_file + File.join(@hive_home, ".web.pid") + end + + def web_log_file + File.join(@hive_home, "logs", "web.log") + end + + def install_service!(force:, json: false) + require "hive/commands/web/service_installer" + installer = ServiceInstaller.new(binary_path: Hive::InvokedBinary.path) + result = installer.install!(autostart: true, force: force) + unless json + installer.messages.each { |line| warn "hive: #{line}" } + case result.kind + when :written then puts "hive web: installed unit at #{installer.target_path}" + when :upgraded + msg = "hive web: upgraded unit at #{installer.target_path}" + msg += " (backup: #{result.backup_path})" if result.backup_path + puts msg + when :unchanged then 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" + end + end + if json + require "json" + puts JSON.generate( + "schema" => "hive-web-install", + "schema_version" => 1, + "ok" => result.success?, + "outcome" => result.wire_outcome, + "platform" => installer.envelope_platform, + "target_path" => installer.target_path, + "backup_path" => result.backup_path, + "restarted" => result.restarted, + "messages" => installer.messages.dup + ) + end + if result.drifted? + raise Hive::Error, + "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)." + elsif result.failed? + raise Hive::Error, "hive web: service install reported a failure; see messages above" + end + result + end + + def start_service!(json: false) + require "hive/commands/web/service_installer" + installer = ServiceInstaller.new(binary_path: Hive::InvokedBinary.path) + state = installer.service_state + started_via = + if state["service_installed"] && installer.envelope_platform != "unsupported" + ok = manager_start(installer) + raise Hive::Error, "hive web: failed to start the #{installer.service_name} service" unless ok + + "service" + else + start_detached! + "detached" + end + puts "hive web: started (#{started_via})" unless json + return unless json + + require "json" + puts JSON.generate( + "schema" => "hive-web-start", + "schema_version" => 1, + "ok" => true, + "via" => started_via + ) + end + + def stop_service!(json: false) + require "hive/commands/web/service_installer" + installer = ServiceInstaller.new(binary_path: Hive::InvokedBinary.path) + state = installer.service_state + was_running = + if state["service_installed"] && installer.envelope_platform != "unsupported" + manager_stop(installer) + else + stop_detached! + end + puts was_running ? "hive web: stopped" : "hive web: not running" unless json + return unless json + + require "json" + puts JSON.generate( + "schema" => "hive-web-stop", + "schema_version" => 1, + "ok" => true, + "was_running" => !!was_running + ) + end + + def service_status(json: false) + require "hive/commands/web/service_installer" + cfg = Hive::Config.load_global_web + bind = @bind || cfg.fetch("bind") + port = (@port || cfg.fetch("port")).to_i + installer = ServiceInstaller.new(binary_path: Hive::InvokedBinary.path) + state = installer.service_state + pid = read_live_pid + running = pid ? true : port_open?(bind, port) + payload = { + "schema" => "hive-web-status", + "schema_version" => 1, + "ok" => true, + "running" => running, + "pid" => pid, + "bind" => bind, + "port" => port, + "service_installed" => state["service_installed"], + "service_enabled" => state["service_enabled"], + "unit_path" => state["unit_path"], + # Same graceful fallback as the daemon guard: the web tier may not + # have been invoked through a hive binary (rails test runner). + "resolved_binary" => Hive::InvokedBinary.path || + Hive::InvokedBinary.which("hive") || "hive" + } + if json + require "json" + puts JSON.generate(payload) + elsif running + puts "hive web: running (#{pid ? "pid #{pid}" : "port #{port} responsive"})" + else + puts "hive web: not running" + end + raise Hive::Error, "web not running" unless running + end + private + def manager_start(installer) + case installer.envelope_platform + when "linux" then system("systemctl", "--user", "start", installer.service_name) + when "macos" then system("launchctl", "load", installer.target_path) + end + end + + def manager_stop(installer) + case installer.envelope_platform + when "linux" then system("systemctl", "--user", "stop", installer.service_name) + when "macos" then system("launchctl", "unload", installer.target_path) + end + end + + # No user service manager (or no unit yet): run the same command the + # unit would have run, detached, with output captured in web.log. + def start_detached! + live = read_live_pid + if live + warn "hive web: already running (pid #{live})" + return + end + FileUtils.mkdir_p(File.dirname(web_log_file)) + log = File.open(web_log_file, "a") + pid = Process.spawn(Hive::InvokedBinary.path, "web", out: log, err: log, pgroup: true) + log.close + File.write(pid_file, pid_file_payload(pid).to_yaml) + Process.detach(pid) + end + + def stop_detached! + pid = read_live_pid + return false unless pid + + send_signal_safely(pid, "TERM") + deadline = Time.now + 10 + while pid_alive?(pid) && Time.now < deadline + # Reap if it is our own child — a TERMed child lingers as a zombie + # until waited, and pid_alive? cannot distinguish zombie from live. + begin + Process.waitpid(pid, Process::WNOHANG) + rescue Errno::ECHILD + break # not our child; nothing to reap + end + sleep 0.2 + end + send_signal_safely(pid, "KILL") if pid_alive?(pid) + begin + Process.waitpid(pid, Process::WNOHANG) + rescue Errno::ECHILD + # not our child + end + FileUtils.rm_f(pid_file) + true + end + + def port_open?(bind, port) + host = LOOPBACK_BINDS.include?(bind.to_s) ? "127.0.0.1" : bind.to_s + ::Socket.tcp(host, port, connect_timeout: 0.5) { |_sock| true } + rescue SystemCallError, IOError + false + end + def rails_app_dir candidates = [ ENV["HIVEBOX_WEB_APP_DIR"], @@ -72,6 +295,58 @@ module Hive candidates.find { |dir| File.file?(File.join(dir, "config", "application.rb")) } end + # Last-resort source for the Rails app on gem installs: ask the + # provisioner for a version-matched copy under XDG data home. Failure + # is a typed error listing what was tried; rails_app_dir candidates + # always win when present. + def provision_app_dir!(cfg) + require "hive/web/app_provisioner" + provisioner = Hive::Web::AppProvisioner.new + dir = provisioner.provision + warn "hive web: using provisioned web app at #{dir}" if dir + dir + rescue Hive::Error => e + warn "hive web: #{e.message}" + nil + end + + # Resolve the effective auth mode from config + bind: + # + # auto → none on a loopback bind, github otherwise (the hivebox + # posture: the container binds 0.0.0.0 and resolves to + # github exactly as before this key existed). + # none → no sign-in; refused outright on a non-loopback bind + # unless --unsafe-public / web.allow_public_unsafe. + # github → owner gate regardless of bind (Docker parity). + # + # The refusal names all three remediations so the operator can fix it + # without reading docs. + def resolve_auth_mode!(bind, cfg) + configured = cfg.fetch("auth", "auto") + loopback = loopback_bind?(bind) + mode = + if configured == "auto" + loopback ? "none" : "github" + else + configured + end + + if mode == "none" && !loopback && !@unsafe_public && !cfg["allow_public_unsafe"] + raise Hive::Error, + "hive web: refusing to bind #{bind} without authentication. " \ + "Pick one: (1) bind loopback instead (`--bind 127.0.0.1`), " \ + "(2) set `web.auth: github` in the global config to require the " \ + "GitHub owner sign-in, or (3) pass `--unsafe-public` to accept an " \ + "unauthenticated network-exposed UI." + end + + mode + end + + def loopback_bind?(bind) + LOOPBACK_BINDS.include?(bind.to_s.strip.downcase) + end + # Rails' production host authorization is inactive by default — the box # assumes a trusted reverse proxy validates Host, exactly like the # pre-Rails posture. Binding a public interface without that proxy diff --git a/lib/hive/commands/web/service_installer.rb b/lib/hive/commands/web/service_installer.rb new file mode 100644 index 000000000..a6526a8b1 --- /dev/null +++ b/lib/hive/commands/web/service_installer.rb @@ -0,0 +1,68 @@ +require "cgi" +require "shellwords" +require "hive/commands/service_installer/base" + +module Hive + module Commands + class Web + # Per-user autostart installer for the web tier (U4) — a SEPARATE + # service from the daemon (R8): systemd-user `hive-web.service` / + # launchd `local.hive-web`. Reuses ServiceInstaller::Base mechanics + # verbatim (backup rotation, force-upgrade restart, unsupported-host + # outcome); only identity + templates differ from the daemon. + 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 + + private + + def render_systemd + template = File.read(File.expand_path("../../../../examples/systemd/hive-web.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. (Same treatment as hive-daemon.service.) + escaped = Shellwords.escape(resolved_binary) + template + .sub(/^ExecStart=.*$/, "ExecStart=#{escaped} web") + .sub(/^Environment=HIVE_BIN=.*$/, "Environment=HIVE_BIN=#{escaped}") + .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 + # dirname BEFORE HTML-escaping so paths with `&`/`<`/`>` get the + # correct directory segmentation; then escape both for plist XML. + binary_dir = File.dirname(binary) + escaped_binary = CGI.escapeHTML(binary) + escaped_binary_dir = CGI.escapeHTML(binary_dir) + escaped_home = CGI.escapeHTML(@home) + template + .gsub(%r{/Users/YOU/\.local/bin/hive}, "#{escaped_binary}") + .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..b68c5cb38 100644 --- a/lib/hive/config.rb +++ b/lib/hive/config.rb @@ -373,6 +373,15 @@ module Hive "bind" => "127.0.0.1", "port" => 4567, "origin" => "http://127.0.0.1:4567", + # Local (non-Docker) auth mode. `auto` resolves at `hive web` boot: + # `none` on a loopback bind (no sign-in for a private localhost UI), + # `github` otherwise — which is exactly the hivebox posture, since + # the container binds 0.0.0.0. `none`/`github` pin the mode + # explicitly. A non-loopback bind with effective auth `none` is + # refused unless web.allow_public_unsafe is true (or the CLI passes + # --unsafe-public). + "auth" => "auto", + "allow_public_unsafe" => false, "github" => { "owner" => nil, # The shared hivebox OAuth app (device flow only — public by @@ -593,6 +602,11 @@ module Hive # deliberate review point here, not a silent propagation — if DIRS # changes, update this list to match. DEPENDENCY_GATE_STAGES = %w[8-finalize 9-done].freeze # coding-scoped: coding dependency-gate stages (last two of Stages::DIRS) + + # Effective auth modes for `hive web`. `auto` is the default and + # resolves to `none` on a loopback bind, `github` otherwise (see + # Hive::Commands::Web#resolve_auth_mode!). + WEB_AUTH_MODES = %w[auto none github].freeze EXPLICIT_CLAUDE_MODE_KEY = :__hive_explicit_claude_mode EXPLICIT_BRAINSTORM_RUNTIME_KEY = :__hive_explicit_brainstorm_runtime @@ -2277,6 +2291,20 @@ module Hive "web.origin in #{describe_source(source_path)} must be an http(s) URL" end + auth = web["auth"] + unless WEB_AUTH_MODES.include?(auth) + raise ConfigError, + "web.auth in #{describe_source(source_path)} must be one of " \ + "#{WEB_AUTH_MODES.inspect}; got #{auth.inspect} (#{auth.class})" + end + + allow_public_unsafe = web["allow_public_unsafe"] + unless allow_public_unsafe.nil? || allow_public_unsafe == true || allow_public_unsafe == false + raise ConfigError, + "web.allow_public_unsafe in #{describe_source(source_path)} must be a boolean " \ + "(true / false); got #{allow_public_unsafe.inspect} (#{allow_public_unsafe.class})" + end + github = web["github"] unless github.is_a?(Hash) raise ConfigError, diff --git a/lib/hive/setup/daemon_guard.rb b/lib/hive/setup/daemon_guard.rb new file mode 100644 index 000000000..794e895e9 --- /dev/null +++ b/lib/hive/setup/daemon_guard.rb @@ -0,0 +1,140 @@ +require "rbconfig" +require "shellwords" + +require "hive" +require "hive/commands/daemon/service_installer" +require "hive/invoked_binary" +require "hive/pid_file" +require "hive/paths" + +module Hive + module Setup + # Daemon binary/version consistency guard (U5). After `hive setup`, the + # hive-daemon user service must run the SAME hive binary as the invoking + # CLI. Upgrades (brew, install.sh, gem update) silently move the binary + # out from under a still-installed unit; this guard detects that drift + # and repairs it by re-running the daemon installer with --force, which + # rewrites the unit to the resolved binary and restarts the service. + # + # Check outcomes: + # ok — unit installed, binary matches the invoking CLI + # drifted — unit installed but points at a different binary + # not_installed — no hive-daemon unit on this host + # unsupported — no systemd-user / launchd on this host (nothing to + # guard; manual `hive daemon start` guidance applies) + # + # Running-state is a separate axis reported alongside (`running`), fed + # by the same pidfile contract `hive daemon status` uses. + class DaemonGuard + include Hive::PidFile + + Result = Struct.new( + :status, :running, :pid, :unit_path, :service_binary, :expected_binary, :detail, + keyword_init: true + ) do + def ok? + status == "ok" + end + + def healthy? + ok? && running + end + end + + attr_reader :expected_binary + + def initialize(home: nil, binary_path: nil, host_os: RbConfig::CONFIG["host_os"], + runner: nil, systemctl_available: nil) + @installer = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: host_os, home: home, binary_path: binary_path, + runner: runner, systemctl_available: systemctl_available + ) + # The web tier may not have been invoked through a hive binary at + # all (Puma under rails test); fall back to a PATH lookup so the + # comparison degrades gracefully instead of crashing. + resolved = binary_path || Hive::InvokedBinary.path || + Hive::InvokedBinary.which("hive") || "hive" + @expected_binary = File.expand_path(resolved) + end + + def pid_file + File.join(Hive::Paths.state_home, ".daemon.pid") + end + + def check + state = @installer.service_state + running = !!read_live_pid + + return Result.new( + status: "unsupported", running: running, pid: read_live_pid, + unit_path: nil, service_binary: nil, expected_binary: @expected_binary, + detail: "no systemd-user / launchd service manager on this host; run `hive daemon start` manually" + ) if state["platform"] == "unsupported" + + unless state["service_installed"] + return Result.new( + status: "not_installed", running: running, pid: read_live_pid, + unit_path: state["unit_path"], service_binary: nil, + expected_binary: @expected_binary, + detail: "hive-daemon unit not installed; run `hive setup` or `hive daemon install`" + ) + end + + service_binary = unit_binary(state["unit_path"]) + if service_binary && !binaries_match?(service_binary, @expected_binary) + return Result.new( + status: "drifted", running: running, pid: read_live_pid, + unit_path: state["unit_path"], service_binary: service_binary, + expected_binary: @expected_binary, + detail: "daemon unit runs #{service_binary} but this CLI is #{@expected_binary}; " \ + "repair with `hive setup` (or `hive daemon install --force`)" + ) + end + + Result.new( + status: "ok", running: running, pid: read_live_pid, + unit_path: state["unit_path"], service_binary: service_binary, + expected_binary: @expected_binary, detail: nil + ) + end + + # Repair = re-run the daemon installer with --force: the unit is + # rewritten to the resolved binary and the service restarts (Linux) / + # reloads (macOS). Idempotent — a matching unit comes back :unchanged. + def repair! + @installer.install!(autostart: true, force: true) + end + + private + + # Extract the hive binary a unit would execute. systemd: first token + # of ExecStart= (Shellwords-escaped at render time). launchd: the + # ProgramArguments entry whose basename is `hive` (the template wraps + # the invocation in /bin/sh, so it is never the first string). + def unit_binary(unit_path) + return nil unless unit_path && File.file?(unit_path) + + content = File.read(unit_path) + if unit_path.end_with?(".service") + line = content.lines.find { |l| l.start_with?("ExecStart=") } + return nil unless line + + Shellwords.split(line.sub(/\AExecStart=/, "").strip).first + else + strings = content.scan(%r{([^<]*)}).flatten + strings.map { |s| CGI.unescapeHTML(s) } + .find { |s| File.basename(s) == "hive" } + end + end + + # Compare through symlinks when both paths exist (brew/install.sh + # symlink chains), falling back to literal equality for paths that + # do not resolve on this host. + def binaries_match?(a, b) + real_a = File.exist?(a) ? File.realpath(a) : a + real_b = File.exist?(b) ? File.realpath(b) : b + real_a == real_b + end + end + end +end diff --git a/lib/hive/setup/preflight.rb b/lib/hive/setup/preflight.rb new file mode 100644 index 000000000..0d20633cf --- /dev/null +++ b/lib/hive/setup/preflight.rb @@ -0,0 +1,231 @@ +require "open3" + +require "hive" +require "hive/config" + +module Hive + module Setup + # `hive setup` dependency preflight / diagnostics (U3). One report that + # verifies the local-mode toolchain — Ruby 3.4, git, tmux, gh, claude, + # codex, Node/npm, qmd, the Rails bundler, and SQLite — with each row + # reporting `present`, `missing`, or `version_too_old` plus the exact + # fix command. + # + # Contract (R12): Hive-owned dependencies (qmd, the web bundle) are + # bootstrap-eligible elsewhere in setup; EXTERNAL agent CLIs + # (`claude`, `codex`, `gh`) are diagnose-only. This class NEVER runs + # installers or login flows for them — it prints the fix command and + # moves on. + # + # Rows use the same status vocabulary as `hive doctor` + # (present / missing / version_too_old) so tooling can treat both + # reports uniformly. + class Preflight + Row = Struct.new(:name, :status, :message, :fix, :required, keyword_init: true) + + EXIT_SUCCESS = 0 + EXIT_HARD_FAILURE = 1 + + # name → probe definition. `min` enables version_too_old detection; + # `required: true` rows fail setup when broken (everything else is a + # loud warning). External CLIs carry `diagnose_only: true`. + CHECKS = [ + { + name: "ruby", required: true, + min: "3.4", + version_argv: %w[ruby -v], + version_from: /\Aruby (\d+\.\d+\.\d+)/, + fix: "install Ruby 3.4 (https://www.ruby-lang.org/en/documentation/installation/)" + }, + { + name: "git", required: true, + version_argv: %w[git --version], + version_from: /\Agit version (\d+\.\d+\.\d+)/, + fix: "install git (apt install git / brew install git)" + }, + { + name: "bundle", required: true, + version_argv: %w[bundle --version], + version_from: /\ABundler version (\d+\.\d+\.\d+)/, + fix: "gem install bundler" + }, + { + name: "tmux", required: false, + min: "3.2", + version_argv: %w[tmux -V], + version_from: /\Atmux (\d+\.\d+)/, + fix: "install tmux 3.2+ (apt install tmux / brew install tmux)" + }, + { + name: "node", required: false, + min: "18", + version_argv: %w[node --version], + version_from: /\Av(\d+)\.\d+\.\d+/, + fix: "install Node.js 18+ (https://nodejs.org) — required for qmd" + }, + { + name: "npm", required: false, + version_argv: %w[npm --version], + version_from: /\A(\d+\.\d+\.\d+)/, + fix: "install npm (ships with Node.js: https://nodejs.org)" + }, + { + name: "claude", required: false, diagnose_only: true, + version_argv: %w[claude --version], + version_from: /\A(\d+\.\d+\.\d+)/, + min: Hive::MIN_CLAUDE_VERSION, + fix: "install claude, then run `claude setup-token` if a token login is wanted" + }, + { + name: "codex", required: false, diagnose_only: true, + version_argv: %w[codex --version], + version_from: /\A(?:codex-cli\s+)?(\d+\.\d+\.\d+)/, + fix: "install codex, then run `codex login --device-auth` to authenticate" + }, + { + name: "gh", required: false, diagnose_only: true, + version_argv: %w[gh --version], + version_from: /\Agh version (\d+\.\d+\.\d+)/, + fix: "install gh (https://cli.github.com), then run `gh auth login`" + }, + { + name: "sqlite3", required: false, + version_argv: %w[sqlite3 --version], + version_from: /\A(\d+\.\d+\.\d+)/, + fix: "install sqlite3 (apt install sqlite3 libsqlite3-dev / brew install sqlite)" + } + ].freeze + + attr_reader :rows + + def initialize(runner: nil, qmd_finder: nil) + @runner = runner || ->(argv) { + begin + out, _err, status = Open3.capture3(*argv) + [ out, status.success? ] + rescue Errno::ENOENT, Errno::EACCES + # binary not installed / not runnable → the "missing" answer + [ "", false ] + end + } + @qmd_finder = qmd_finder || method(:find_qmd) + @rows = nil + end + + def call + @rows = CHECKS.map { |check| check_row(check) } + @rows << qmd_row + @rows + end + + def hard_failures + rows.select { |row| row.required && row.status != "present" }.map(&:name) + end + + def warnings + rows.reject(&:required).select { |row| row.status != "present" }.map(&:name) + end + + def render(output = $stdout) + width = rows.map { |r| r.name.length }.max + rows.each do |row| + line = format(" %-#{width}s %-14s %s", row.name, "[#{row.status}]", row.message) + output.puts line + if row.status != "present" && row.fix && !row.fix.empty? + output.puts format(" %-#{width}s └─ fix: %s", "", row.fix) + end + end + end + + private + + def check_row(check) + out, ok = @runner.call(check.fetch(:version_argv)) + unless ok + return Row.new( + name: check.fetch(:name), status: "missing", + message: "not found on PATH", + fix: check.fetch(:fix), required: check.fetch(:required) + ) + end + + version = out.lines.first.to_s.strip[check.fetch(:version_from), 1] + min = check[:min] + if min && version && version_lt(version, min) + return Row.new( + name: check.fetch(:name), status: "version_too_old", + message: "#{version} found, #{min}+ required", + fix: check.fetch(:fix), required: check.fetch(:required) + ) + end + + message = version ? "version #{version}" : out.lines.first.to_s.strip + message += auth_suffix(check) if check.fetch(:name) == "gh" + Row.new( + name: check.fetch(:name), status: "present", + message: message, fix: nil, required: check.fetch(:required) + ) + end + + # Diagnose-only auth probe for gh (R12): report an unauthenticated + # CLI loudly, never attempt `gh auth login`. The row stays `present` + # — the binary works; only its credentials are missing. + def auth_suffix(_check) + gh_auth_out, gh_auth_ok = @runner.call(%w[gh auth status]) + gh_auth_ok ? "" : " — NOT authenticated; fix: gh auth login" + rescue StandardError + "" + end + + def qmd_row + qmd = @qmd_finder.call + return Row.new( + name: "qmd", status: "missing", + message: "not installed (Hive-owned; setup can bootstrap it)", + fix: 'npm install --global --prefix "${XDG_DATA_HOME:-$HOME/.local/share}/hive/qmd" @tobilu/qmd', + required: false + ) unless qmd + + Row.new(name: "qmd", status: "present", message: "found at #{qmd}", fix: nil, required: false) + end + + # Same resolution order as hive doctor: HIVE_QMD_BIN, PATH, then the + # managed prefix install.sh bootstraps into. + def find_qmd + env_qmd = ENV["HIVE_QMD_BIN"].to_s + return env_qmd if !env_qmd.empty? && File.executable?(env_qmd) + + path_qmd = which("qmd") + return path_qmd if path_qmd + + data_home = ENV["XDG_DATA_HOME"].to_s.empty? ? File.expand_path("~/.local/share") : ENV["XDG_DATA_HOME"] + candidates = [ + File.join(data_home, "hive", "qmd", "bin", "qmd"), + File.expand_path("~/.local/share/hive/qmd/bin/qmd") + ] + prefix_file = File.join(data_home, "hive", "install-prefix") + if File.readable?(prefix_file) + prefix = File.read(prefix_file).lines.first.to_s.strip + candidates << File.join(prefix, "hive", "qmd", "bin", "qmd") unless prefix.empty? + end + candidates.find { |candidate| File.file?(candidate) && File.executable?(candidate) } + end + + 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 + + # Dotted-version compare via Gem::Version (handles "3.2" vs "3.10" + # and pre-release suffixes the way bundler does). + def version_lt(a, b) + Gem::Version.new(a) < Gem::Version.new(b) + rescue ArgumentError + a.to_s < b.to_s + end + end + end +end diff --git a/lib/hive/web/app_provisioner.rb b/lib/hive/web/app_provisioner.rb new file mode 100644 index 000000000..9a5ef7b27 --- /dev/null +++ b/lib/hive/web/app_provisioner.rb @@ -0,0 +1,241 @@ +require "cgi" +require "digest" +require "fileutils" +require "net/http" +require "tmpdir" + +require "hive" +require "hive/paths" + +module Hive + module Web + # Provisions a version-matched copy of the hivebox Rails web app for + # gem installs (U2). The released `hive-cli` gem deliberately does NOT + # package web/ (pinned by test/unit/gemspec_test.rb), so a gem-installed + # `hive web` has nothing to run outside a source checkout or the Docker + # image. This class fills that gap: + # + # target dir: ${XDG_DATA_HOME:-~/.local/share}/hive/web-app/ + # + # Sources, in the order `Hive::Commands::Web` consults them: + # 1. HIVEBOX_WEB_APP_DIR override (manual escape hatch — handled by + # the caller, never here) + # 2. source checkout next to lib/ (handled by the caller) + # 3. release asset download: hive-web-.tar.gz (+ .sha256) + # from the GitHub release matching Hive::VERSION + # + # After extraction the embedded `gem "hive-cli", path: ".."` dependency + # is rewritten to point at the installed gem (a downloaded tarball has + # no parent checkout), then `bundle install` runs with its path scoped + # INSIDE the app dir so nothing leaks into the user's global bundle. + # + # Failure is always a typed Hive::Error with manual instructions — a + # half-provisioned state is impossible because extraction happens into + # a temp dir that is renamed into place only after bundling succeeds. + class AppProvisioner + VERSION_FILE = ".hive-provisioned".freeze + + attr_reader :target_dir + + def initialize(version: Hive::VERSION, data_home: nil, download: nil, runner: nil) + @version = version + @data_home = data_home || Hive::Paths.data_home + @download = download || method(:download_file) + @runner = runner || ->(env, *argv) { system(env, *argv) } + @target_dir = File.join(@data_home, "web-app", @version) + end + + # Returns the provisioned app dir when it exists (idempotent), or nil + # when provisioning failed (the error has already been reported via + # Hive::Error by the internal steps... no — raises). Raises + # Hive::Error on any failure; returns the target dir path on success. + def provision + return @target_dir if already_provisioned? + + # A leftover incomplete dir from a crashed prior run is safe to + # replace: already_provisioned? just said it is not complete. + FileUtils.rm_rf(@target_dir) + FileUtils.mkdir_p(File.dirname(@target_dir)) + tarball = fetch_release_asset + verify_checksum!(tarball) + extract_atomically!(tarball) + rewrite_hive_cli_path! + bundle_install! + mark_provisioned! + + warn "hive web: provisioned web app #{@version} at #{@target_dir}" + @target_dir + ensure + FileUtils.rm_f(tarball) if defined?(tarball) && tarball&.start_with?(Dir.tmpdir) + end + + # True when the target dir holds a complete, previously-provisioned + # app. Guards idempotent re-runs (`hive setup` twice must not + # re-download). + def already_provisioned? + File.file?(File.join(@target_dir, "config", "application.rb")) && + File.file?(File.join(@target_dir, "bin", "rails")) && + File.file?(File.join(@target_dir, VERSION_FILE)) + end + + private + + def asset_base_url + "https://github.com/#{Hive::REPO_OWNER}/#{Hive::REPO_NAME}/releases/download/v#{@version}" + end + + def fetch_release_asset + Dir.mktmpdir("hive-web-dl") do |dl_dir| + tarball = File.join(dl_dir, "hive-web-#{@version}.tar.gz") + begin + @download.call("#{asset_base_url}/hive-web-#{@version}.tar.gz", tarball) + rescue StandardError => e + raise Hive::Error, + "could not download the hive web app (hive-web-#{@version}.tar.gz): " \ + "#{e.class}: #{e.message}. Check your network, or point " \ + "HIVEBOX_WEB_APP_DIR at an existing Rails app checkout to skip provisioning." + end + # Copy out of the tmpdir scope so the caller's cleanup doesn't + # race the extraction step. + keep = File.join(Dir.tmpdir, "hive-web-#{Process.pid}-#{rand(1_000_000)}.tar.gz") + FileUtils.cp(tarball, keep) + keep + end + end + + def verify_checksum!(tarball) + expected = Dir.mktmpdir("hive-web-sum") do |sum_dir| + sums = File.join(sum_dir, "SHA256") + begin + @download.call("#{asset_base_url}/hive-web-#{@version}.tar.gz.sha256", sums) + rescue StandardError => e + raise Hive::Error, + "could not download the hive web app checksum (.sha256): #{e.class}: #{e.message}" + end + File.read(sums).strip.split(/\s+/).first + end + + actual = ::Digest::SHA256.file(tarball).hexdigest + return if expected && actual == expected.downcase + + raise Hive::Error, + "hive web app download failed checksum verification " \ + "(expected #{expected}, got #{actual}); refusing to install a " \ + "tampered or corrupted archive. Delete #{@target_dir} and retry." + end + + def extract_atomically!(tarball) + staging = "#{@target_dir}.staging.#{Process.pid}" + FileUtils.rm_rf(staging) + FileUtils.mkdir_p(staging) + begin + ok = @runner.call({}, "tar", "-xzf", tarball, "-C", staging) + unless ok + raise Hive::Error, + "failed to extract the hive web app archive into #{staging}; " \ + "is `tar` available on PATH?" + end + # Tolerate both a bare app tree and a single top-level directory. + src = if File.file?(File.join(staging, "config", "application.rb")) + staging + else + entries = Dir.children(staging) + raise Hive::Error, "hive web app archive is missing config/application.rb" unless entries.size == 1 + + File.join(staging, entries.first) + end + unless File.file?(File.join(src, "config", "application.rb")) + raise Hive::Error, "hive web app archive is missing config/application.rb" + end + + # provision/ removed any stale target up front, so this rename + # lands the extracted tree in place atomically. + FileUtils.mv(src, @target_dir) + prune_non_runtime_dirs! + ensure + FileUtils.rm_rf(staging) if File.directory?(staging) + end + end + + # Dev/test gems and CI configs are stripped from the release tarball at + # build time; this is belt-and-braces for archives built by older jobs. + def prune_non_runtime_dirs! + %w[spec test tmp storage].each do |dir| + FileUtils.rm_rf(File.join(@target_dir, dir)) + end + end + + # A downloaded tarball has no parent checkout, but web/Gemfile pins + # `gem "hive-cli", path: ".."`. Rewrite it to the installed gem's real + # path so `bundle install` resolves. + def rewrite_hive_cli_path! + gemfile = File.join(@target_dir, "Gemfile") + raise Hive::Error, "provisioned web app is missing a Gemfile" unless File.file?(gemfile) + + gem_path = + begin + Gem::Specification.find_by_name("hive-cli").full_gem_path + rescue Gem::LoadError + nil + end + raise Hive::Error, + "cannot rewrite the provisioned web app's hive-cli dependency: " \ + "the hive-cli gem is not installed where Ruby can see it" unless gem_path + + text = File.read(gemfile) + rewritten = text.gsub(/gem\s+"hive-cli",\s*path:[^\n]*/, + "gem \"hive-cli\", path: #{gem_path.inspect}") + raise Hive::Error, "provisioned web/Gemfile has no hive-cli path dependency to rewrite" if rewritten == text + + File.write(gemfile, rewritten) + end + + def bundle_install! + env = { + "BUNDLE_GEMFILE" => File.join(@target_dir, "Gemfile"), + # Scope the bundle INSIDE the app dir — never pollute the user's + # global gem home with the web tier's dependency tree. + "BUNDLE_PATH" => File.join(@target_dir, ".bundle"), + "BUNDLE_APP_CONFIG" => File.join(@target_dir, ".bundle", "config") + } + ok = @runner.call(env, "bundle", "install") + unless ok + raise Hive::Error, + "bundle install failed for the provisioned web app. Retry manually: " \ + "cd #{@target_dir} && bundle install (or set HIVEBOX_WEB_APP_DIR to a " \ + "checkout of web/ matching hive #{@version})." + end + end + + def mark_provisioned! + File.write(File.join(@target_dir, VERSION_FILE), "#{@version}\n") + end + + # Minimal HTTPS downloader with redirect following (GitHub release + # assets redirect to S3). No third-party deps. + def download_file(url, dest) + uri = URI.parse(url) + redirects = 0 + loop do + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| + request = Net::HTTP::Get.new(uri.request_uri) + request["User-Agent"] = "hive-cli/#{Hive::VERSION}" + http.request(request) + end + case response + when Net::HTTPRedirection + redirects += 1 + raise Hive::Error, "too many redirects downloading #{url}" if redirects > 5 + + uri = URI.parse(response["location"]) + when Net::HTTPSuccess + File.binwrite(dest, response.body) + return dest + else + raise Hive::Error, "HTTP #{response.code} fetching #{url}" + end + end + end + end + end +end diff --git a/test/integration/setup_enroll_test.rb b/test/integration/setup_enroll_test.rb new file mode 100644 index 000000000..27e1180b8 --- /dev/null +++ b/test/integration/setup_enroll_test.rb @@ -0,0 +1,120 @@ +require "test_helper" +require "hive/commands/init" +require "hive/commands/setup" + +# U6 — `hive setup` leaves the target project enrolled (daemon.enabled: +# true), reusing Init and the daemon enable machinery; setup adds no new +# enrollment semantics. +class SetupEnrollTest < Minitest::Test + include HiveTestHelper + + def test_fresh_repo_is_initialised_with_daemon_enabled + with_tmp_global_config do |_dir| + with_tmp_git_repo do |repo| + result = run_setup(project: repo) + + enroll = result.steps.find { |s| s.name == "enroll" } + assert_equal "ok", enroll.status, enroll.detail.to_s + cfg_path = File.join(repo, ".hive-state", "config.yml") + assert File.file?(cfg_path), "setup must have initialised the project" + data = YAML.safe_load_file(cfg_path) + assert_equal true, data.dig("daemon", "enabled"), + "Init defaults daemon.enabled: true — setup must preserve it" + end + end + end + + def test_legacy_disabled_project_is_flipped_to_enabled + with_tmp_global_config do |_dir| + with_tmp_git_repo do |repo| + capture_io { Hive::Commands::Init.new(repo, force: true).call } + flip_daemon_enabled(repo, false) + + result = run_setup(project: repo) + + enroll = result.steps.find { |s| s.name == "enroll" } + assert_equal "ok", enroll.status, enroll.detail.to_s + data = YAML.safe_load_file(File.join(repo, ".hive-state", "config.yml")) + assert_equal true, data.dig("daemon", "enabled"), "a legacy disabled project must be flipped on" + end + end + end + + def test_already_enrolled_project_is_a_noop + with_tmp_global_config do |_dir| + with_tmp_git_repo do |repo| + capture_io { Hive::Commands::Init.new(repo, force: true).call } + + result = run_setup(project: repo) + enroll = result.steps.find { |s| s.name == "enroll" } + assert_equal "ok", enroll.status + assert_match(/already enrolled/, enroll.detail) + end + end + end + + def test_skip_enroll_leaves_the_project_untouched + with_tmp_global_config do |_dir| + with_tmp_git_repo do |repo| + capture_io { Hive::Commands::Init.new(repo, force: true).call } + flip_daemon_enabled(repo, false) + + result = run_setup(project: repo, run: []) + + enroll = result.steps.find { |s| s.name == "enroll" } + assert_equal "skipped", enroll.status + data = YAML.safe_load_file(File.join(repo, ".hive-state", "config.yml")) + assert_equal false, data.dig("daemon", "enabled"), "--skip-enroll must not touch project config" + end + end + end + + private + + def flip_daemon_enabled(repo, value) + cfg_path = File.join(repo, ".hive-state", "config.yml") + text = File.read(cfg_path) + raise "fixture expected a daemon block in #{cfg_path}" unless text =~ /^daemon:$/ + + lines = text.split("\n", -1) + daemon_idx = lines.index { |line| line == "daemon:" } + raise "fixture expected a daemon block in #{cfg_path}" unless daemon_idx + + enabled_idx = nil + lines[(daemon_idx + 1)..].each_with_index do |line, offset| + break unless line.empty? || line.start_with?("#", " ") + next if line.empty? || line.lstrip.start_with?("#") + + if line =~ /\A enabled:/ + enabled_idx = daemon_idx + 1 + offset + break + end + end + + if enabled_idx + lines[enabled_idx] = lines[enabled_idx].sub(/\A( enabled:[ \t]+).*$/, "\\1#{value}") + else + lines.insert(daemon_idx + 1, " enabled: #{value}") + end + File.write(cfg_path, lines.join("\n")) + data = YAML.safe_load_file(cfg_path) + assert_equal value, data.dig("daemon", "enabled"), "fixture flip failed" + end + + def run_setup(project:, run: %w[enroll]) + require "hive/commands/init" +require "hive/commands/setup" + command = Hive::Commands::Setup.new( + project: project, + skip_preflight: !run.include?("preflight"), + skip_web_app: !run.include?("web_app"), + skip_daemon: !run.include?("daemon"), + skip_enroll: !run.include?("enroll"), + skip_web: !run.include?("web") + ) + # Everything except enroll runs real external machinery; skip it by + # default so this suite stays hermetic. + capture_io { command.call } + command + end +end diff --git a/test/integration/setup_orchestration_test.rb b/test/integration/setup_orchestration_test.rb new file mode 100644 index 000000000..64d1c93c6 --- /dev/null +++ b/test/integration/setup_orchestration_test.rb @@ -0,0 +1,223 @@ +require "test_helper" +require "hive/commands/setup" +require "hive/setup/preflight" +require "hive/setup/daemon_guard" +require "net/http" + +# U7 — `hive setup` orchestration: step ordering, idempotent re-runs, the +# JSON summary envelope, partial-failure reporting, and the end-to-end web +# step against a fixture HTTP server answering /health?deep=1. +class SetupOrchestrationTest < Minitest::Test + include HiveTestHelper + + def setup + @provision_calls = [] + @fixture_app_dir = nil + end + + def test_happy_path_runs_steps_and_prints_the_real_url + with_fixture_stack do |setup, port| + out, _err = capture_io { assert setup.call } + + assert_match(/✔ preflight/, out) + assert_match(/✔ web_app/, out) + assert_match(/✔ daemon/, out) + assert_match(/✔ web/, out) + assert_match(%r{http://127.0.0.1:#{port}}, out) # the printed URL must be the real bound address + end + end + + def test_json_envelope_reports_steps_ok_with_url + with_fixture_stack do |_setup, port| + setup = build_setup(port: port, json: true) + out, _err = capture_io { assert setup.call } + envelope = JSON.parse(out) + + assert_equal "hive-setup", envelope.fetch("schema") + assert_equal true, envelope.fetch("ok") + names = envelope["steps"].map { |s| s["name"] } + assert_equal %w[preflight web_app daemon web], names & %w[preflight web_app daemon web] + assert_equal "skipped", envelope["steps"].find { |s| s["name"] == "enroll" }["status"] + assert envelope["steps"].all? { |s| s["status"] == "ok" || s["status"] == "skipped" } + assert_equal "http://127.0.0.1:#{port}", envelope.fetch("url") + end + end + + def test_rerun_is_idempotent_no_reprovision + with_fixture_stack do |_setup, port| + @provision_calls.clear + setup = build_setup(port: port) + capture_io { assert setup.call } + downloads_before = @provision_calls.size + + second = build_setup(port: port) + capture_io { assert second.call } + + assert_equal downloads_before, @provision_calls.size, + "re-running setup must not re-provision the web app" + end + end + + def test_missing_hard_dependency_fails_preflight_but_web_step_still_completes + with_fixture_stack do |_setup, port| + broken_ruby = green_preflight("ruby -v" => [ "", false ]) + setup = build_setup(port: port, preflight: broken_ruby) + + out, _err = capture_io { refute setup.call, "a hard dependency failure must fail the run" } + + assert_match(/✘ preflight/, out) + assert_match(/ruby/, out) # the broken row is named loudly + # Partial failure does not abort the remaining steps: web still + # verifies healthy and the URL is printed. + assert_match(/http:\/\/127\.0\.0\.1:#{port}/, out) + end + end + + def test_port_conflict_is_reported_with_owner_and_never_killed + with_tmp_global_config do |dir| + port = free_port + write_global_web_port(dir, port) + # A foreign listener owns the port and stays alive throughout. + blocker = TCPServer.new("127.0.0.1", port) + + setup = Hive::Commands::Setup.new( + skip_preflight: true, skip_daemon: true, skip_enroll: true, + provisioner: fake_provisioner + ) + _out, _err = capture_io { refute setup.call } + + web_step = setup.steps.find { |s| s.name == "web" } + assert_equal "failed", web_step.status + assert_match(/already taken by pid/, web_step.detail) # the conflict report must name the owning pid + ensure + blocker.close + end + end + + private + + # Full sandbox: HIVE_HOME + a fixture web server answering /health?deep=1 + # on the configured port. Enrollment is skipped — covered by U6's suite. + def with_fixture_stack + with_tmp_global_config do |dir| + @provision_calls = [] + @fixture_app_dir = File.join(Dir.mktmpdir("hive-setup-app"), "web-app") + FileUtils.mkdir_p(File.join(@fixture_app_dir, "config")) + File.write(File.join(@fixture_app_dir, "config", "application.rb"), "# fixture\n") + port = free_port + write_global_web_port(dir, port) + server = start_fixture_health_server(port) + setup = build_setup(port: port) + yield setup, port + ensure + server&.close + @fixture_thread&.kill + end + end + + def build_setup(port:, preflight: nil, json: false) + Hive::Commands::Setup.new( + skip_preflight: false, + skip_enroll: true, + json: json, + preflight: preflight || green_preflight, + guard: fake_guard, + provisioner: fake_provisioner + ).tap do |setup| + # Force the provisioning path even inside a source checkout so the + # provisioner seam is actually exercised. + setup.define_singleton_method(:web_app_dir) { nil } + end + end + + def green_preflight(overrides = {}) + probes = { + "ruby -v" => [ "ruby 3.4.1\n", true ], + "git --version" => [ "git version 2.43.0\n", true ], + "bundle --version" => [ "Bundler version 2.5\n", true ], + "tmux -V" => [ "tmux 3.4\n", true ], + "node --version" => [ "v20.0.0\n", true ], + "npm --version" => [ "10.0.0\n", true ], + "claude --version" => [ "#{Hive::MIN_CLAUDE_VERSION}\n", true ], + "codex --version" => [ "codex-cli 0.9\n", true ], + "gh --version" => [ "gh version 2.62\n", true ], + "gh auth status" => [ "", true ], + "sqlite3 --version" => [ "3.45.0\n", true ] + }.merge(overrides) + Hive::Setup::Preflight.new(runner: ->(argv) { probes[argv.join(" ")] || [ "", false ] }, + qmd_finder: -> { "/fake/qmd" }) + end + + def fake_guard + check_result = Hive::Setup::DaemonGuard::Result.new( + status: "ok", running: true, pid: 42_424, + unit_path: "/fake/hive-daemon.service", + service_binary: "/fake/bin/hive", expected_binary: "/fake/bin/hive", + detail: nil + ) + guard = Object.new + guard.define_singleton_method(:check) { check_result } + guard.define_singleton_method(:pid_file) { "" } + guard + end + + def fake_provisioner + app_dir = @fixture_app_dir ||= File.join(Dir.mktmpdir("hive-setup-app"), "web-app") + FileUtils.mkdir_p(File.join(app_dir, "config")) + File.write(File.join(app_dir, "config", "application.rb"), "# fixture\n") unless File.file?(File.join(app_dir, "config", "application.rb")) + # Mirrors the real AppProvisioner contract: the on-disk marker file + # makes provisioning idempotent ACROSS setup runs (no re-download). + marker = File.join(app_dir, ".hive-provisioned") + calls = @provision_calls + Object.new.tap do |prov| + prov.define_singleton_method(:provision) do + calls << :provisioned unless File.exist?(marker) + FileUtils.touch(marker) + app_dir + end + end + end + + # A tiny real HTTP server standing in for the Rails /health endpoint. + def start_fixture_health_server(port) + server = TCPServer.new("127.0.0.1", port) + @fixture_thread = Thread.new do + loop do + client = begin + server.accept + rescue StandardError + next + end + request = +"" + while (line = client.gets) && line != "\r\n" + request << line + end + if request.start_with?("GET /health") + body = '{"ok":true}' + client.write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.length}\r\n\r\n#{body}") + else + client.write("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + end + client.close + rescue StandardError + next + end + end + server + end + + def free_port + server = TCPServer.new("127.0.0.1", 0) + port = server.addr[1] + server.close + port + end + + def write_global_web_port(dir, port) + path = File.join(dir, "config.yml") + data = File.exist?(path) ? YAML.safe_load_file(path) : {} + data["registered_projects"] ||= [] + data["web"] = { "bind" => "127.0.0.1", "port" => port } + File.write(path, data.to_yaml) + end +end diff --git a/test/unit/cli_test.rb b/test/unit/cli_test.rb index dafa1c291..ba38ffb8b 100644 --- a/test/unit/cli_test.rb +++ b/test/unit/cli_test.rb @@ -519,7 +519,7 @@ 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) { |bind:, port:, unsafe_public: false| captured << { bind: bind, port: port } } define_method(:call) { captured << :called } 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..574bcb74a --- /dev/null +++ b/test/unit/commands/web/service_installer_test.rb @@ -0,0 +1,110 @@ +require "test_helper" +require "hive/commands/web/service_installer" + +# U4 — managed web service unit rendering/installation, mirroring +# DaemonServiceInstallerTest's shape. The web tier is its own unit +# (`hive-web`), never merged with the daemon. +class WebServiceInstallerTest < Minitest::Test + include HiveTestHelper + + def test_linux_writes_systemd_unit_with_resolved_binary_and_web_verb + 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(0o755, hive) + + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux-gnu", + home: dir, + binary_path: hive, + systemctl_available: true, + runner: ->(argv) { commands << argv } + ) + + result = installer.install!(autostart: true) + assert_equal :written, result.kind + unit = File.join(dir, ".config/systemd/user/hive-web.service") + assert File.exist?(unit), "the web unit is separate from hive-daemon.service" + content = File.read(unit) + assert_includes content, "ExecStart=#{hive} web" + assert_includes content, "Environment=HIVE_BIN=#{hive}" + refute_includes content, "ExecStart=.*daemon" + refute_match(/^ExecStart=.*daemon/, content) + assert_equal %w[systemctl --user daemon-reload], commands[0] + assert_includes commands[1], "--now" + assert_includes commands[1], "hive-web" + end + end + + def test_macos_writes_plist_with_local_hive_web_label + with_tmp_dir do |dir| + commands = [] + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "darwin23", + home: dir, + binary_path: "/opt/hive/bin/hive", + runner: ->(argv) { commands << argv } + ) + + result = installer.install!(autostart: true) + assert_equal :written, result.kind + plist = File.join(dir, "Library/LaunchAgents/local.hive-web.plist") + assert File.exist?(plist) + content = File.read(plist) + assert_includes content, "local.hive-web" + assert_includes content, "/opt/hive/bin/hive" + assert_includes content, "web" + assert_includes content, "#{dir}/Library/Logs/hive-web.out.log" + assert_includes commands, [ "launchctl", "load", plist ] + end + end + + def test_drifted_unit_refuses_without_force_and_backs_up_with_force + 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) { true } + ) + # First install seeds the unit; hand-edit it to simulate drift. + installer.install!(autostart: false) + unit = File.join(dir, ".config/systemd/user/hive-web.service") + File.write(unit, File.read(unit) + "# hand edit\n") + + drifted = installer.install!(autostart: false) + assert_equal :drifted, drifted.kind + assert installer.messages.any? { |msg| msg.include?("install --force") } + + forced = installer.install!(autostart: false, force: true) + assert_equal :upgraded, forced.kind + assert_forced_backup_present(unit) + end + end + + def test_unsupported_host_reports_unsupported_outcome + with_tmp_dir do |dir| + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "mswin", + home: dir, + binary_path: "/tmp/hive" + ) + + result = installer.install!(autostart: true) + assert_equal :unsupported, result.kind + assert_equal "unsupported", installer.envelope_platform + assert installer.messages.any? { |msg| msg.include?("hive web") } + end + end + + private + + def assert_forced_backup_present(unit) + backups = Dir.glob("#{unit}.bak-*") + assert backups.size == 1, "expected exactly one timestamped backup, got #{backups.inspect}" + assert_includes File.read(backups.first), "# hand edit" + end +end diff --git a/test/unit/commands/web_auth_test.rb b/test/unit/commands/web_auth_test.rb new file mode 100644 index 000000000..51750883a --- /dev/null +++ b/test/unit/commands/web_auth_test.rb @@ -0,0 +1,166 @@ +require "test_helper" +require "hive/commands/web" + +# U1 — effective auth-mode resolution and the non-loopback no-auth refusal +# in `hive web`. Kernel.exec / db:prepare are stubbed so `call` is observed +# without ever booting Rails. +class WebAuthCommandTest < Minitest::Test + include HiveTestHelper + + def test_loopback_default_resolves_auth_mode_none + with_tmp_global_config do |dir| + result = run_web(dir) + + assert_nil result[:error] + assert_equal "none", result[:auth_mode] + assert_equal "none", result[:env].fetch("HIVEBOX_AUTH_MODE") + end + end + + # Plan scenario 2 as literally written (--bind 0.0.0.0 + default auth ⇒ + # refusal) conflicts with R16: the hivebox supervisor runs exactly + # `hive web --bind 0.0.0.0` with default config, and R16 pins that path. + # Per the plan's Approach text ("auto ... resolves to github exactly as + # today"), the refusal fires only when the EFFECTIVE auth mode is `none`. + def test_non_loopback_bind_with_default_auth_resolves_to_github + with_tmp_global_config do |dir| + result = run_web(dir, bind: "0.0.0.0") + + assert_nil result[:error] + assert_equal "github", result[:auth_mode] + end + end + + def test_non_loopback_bind_with_explicit_none_auth_is_refused + with_tmp_global_config do |dir| + File.write(File.join(dir, "config.yml"), { + "registered_projects" => [], + "web" => { "auth" => "none" } + }.to_yaml) + + result = run_web(dir, bind: "0.0.0.0") + + assert_instance_of Hive::Error, result[:error] + assert_match(/refusing to bind 0\.0\.0\.0 without authentication/, result[:error].message) + assert_match(/--bind 127\.0\.0\.1/, result[:error].message) + assert_match(/web\.auth: github/, result[:error].message) + assert_match(/--unsafe-public/, result[:error].message) + end + end + + def test_non_loopback_bind_with_unsafe_public_starts_with_github_auth + with_tmp_global_config do |dir| + result = run_web(dir, bind: "0.0.0.0", unsafe_public: true) + + assert_nil result[:error] + assert_equal "github", result[:auth_mode] + end + end + + def test_non_loopback_bind_with_allow_public_unsafe_config_and_none_auth_boots + with_tmp_global_config do |dir| + File.write(File.join(dir, "config.yml"), { + "registered_projects" => [], + "web" => { "auth" => "none", "allow_public_unsafe" => true } + }.to_yaml) + + result = run_web(dir, bind: "0.0.0.0") + + assert_nil result[:error] + assert_equal "none", result[:auth_mode] + end + end + + def test_explicit_github_auth_gates_even_on_loopback + with_tmp_global_config do |dir| + File.write(File.join(dir, "config.yml"), { + "registered_projects" => [], + "web" => { "auth" => "github" } + }.to_yaml) + + result = run_web(dir) + + assert_equal "github", result[:auth_mode] + end + end + + def test_explicit_none_auth_with_non_loopback_config_bind_is_refused + with_tmp_global_config do |dir| + File.write(File.join(dir, "config.yml"), { + "registered_projects" => [], + "web" => { "bind" => "192.168.1.10", "auth" => "none" } + }.to_yaml) + + result = run_web(dir) + + assert_match(/refusing to bind 192\.168\.1\.10 without authentication/, result[:error].message) + end + end + + def test_explicit_none_auth_with_non_loopback_bind_and_unsafe_flag_boots + with_tmp_global_config do |dir| + File.write(File.join(dir, "config.yml"), { + "registered_projects" => [], + "web" => { "bind" => "192.168.1.10", "auth" => "none" } + }.to_yaml) + + result = run_web(dir, unsafe_public: true) + + assert_nil result[:error] + assert_equal "none", result[:auth_mode] + end + end + def test_localhost_and_ipv6_loopback_binds_are_recognized + web = Hive::Commands::Web.new + + %w[127.0.0.1 localhost ::1 LOCALHOST].each do |bind| + assert web.send(:loopback_bind?, bind), "#{bind} should count as loopback" + end + refute web.send(:loopback_bind?, "0.0.0.0") + refute web.send(:loopback_bind?, "192.168.1.10") + end + + private + + RunResult = Struct.new(:env, :auth_mode, :error) + + # Runs Hive::Commands::Web#call against a fake app dir with the + # db:prepare system call and the final Kernel.exec stubbed out. + # Returns a RunResult with the env hash passed to exec, the resolved + # auth mode, and any typed Hive::Error raised on the refusal path. + def run_web(config_dir, bind: nil, unsafe_public: false) + app_dir = File.join(config_dir, "fake-web-app") + FileUtils.mkdir_p(File.join(app_dir, "config")) + FileUtils.mkdir_p(File.join(app_dir, "bin")) + File.write(File.join(app_dir, "config", "application.rb"), "# fixture\n") + File.write(File.join(app_dir, "bin", "rails"), "#!/bin/sh\n") + + env_holder = {} + error = nil + + original_exec = Kernel.method(:exec) + Kernel.define_singleton_method(:exec) do |*args| + env_holder.replace(args.first.is_a?(Hash) ? args.first : {}) + raise SystemExit # unwind like a real exec path would + end + + with_env("HIVEBOX_WEB_APP_DIR" => app_dir, "RAILS_ENV" => "test") do + web = Hive::Commands::Web.new(bind: bind, port: nil, unsafe_public: unsafe_public) + # Stub the private Kernel#system call used for db:prepare. + web.define_singleton_method(:system) { |*_args| true } + capture_io do + begin + web.call + rescue SystemExit + # expected on the success path + rescue Hive::Error + error = $ERROR_INFO + end + end + ensure + Kernel.define_singleton_method(:exec, original_exec) + end + + RunResult.new(env_holder, env_holder["HIVEBOX_AUTH_MODE"], error) + end +end diff --git a/test/unit/commands/web_lifecycle_test.rb b/test/unit/commands/web_lifecycle_test.rb new file mode 100644 index 000000000..117ae2e1b --- /dev/null +++ b/test/unit/commands/web_lifecycle_test.rb @@ -0,0 +1,132 @@ +require "test_helper" +require "hive/commands/web" + +# U4 — web service lifecycle round-trips (start/stop/status) on the +# detached-pidfile fallback path (no systemd-user in CI), plus the +# status envelope shape. +class WebLifecycleTest < Minitest::Test + include HiveTestHelper + + def test_status_json_reports_not_running_with_service_state + with_tmp_global_config do |dir| + command = Hive::Commands::Web.new + out = capture_io_json { ignore_failure { command.service_status(json: true) } } + + assert_equal "hive-web-status", out.fetch("schema") + assert_equal false, out.fetch("running") + assert_equal false, out.fetch("service_installed") + assert_equal false, out.fetch("service_enabled") + assert_match(/hive-web\.service$/, out.fetch("unit_path")) + refute_empty out.fetch("resolved_binary") + end + end + + def test_status_detects_running_pid_from_pidfile + with_tmp_global_config do |dir| + command = Hive::Commands::Web.new + FileUtils.mkdir_p(File.dirname(command.pid_file)) + File.write(command.pid_file, command.pid_file_payload(Process.pid).to_yaml) + + out = capture_io_json { command.service_status(json: true) } + + assert_equal true, out.fetch("running") + assert_equal Process.pid, out.fetch("pid") + ensure + FileUtils.rm_f(command.pid_file) + end + end + + def test_stop_and_start_round_trip_on_detached_path + with_tmp_global_config do |dir| + # A long-lived stand-in "web server" the lifecycle can stop. + sleeper = spawn("sleep", "30", pgroup: true) + command = Hive::Commands::Web.new + FileUtils.mkdir_p(File.dirname(command.pid_file)) + File.write(command.pid_file, command.pid_file_payload(sleeper).to_yaml) + + # The unit is not installed here, so stop takes the detached path. + command.stop_service!(json: false) rescue nil + wait_until { !process_alive?(sleeper) } + + refute process_alive?(sleeper), "stop must TERM the detached web pid" + refute File.exist?(command.pid_file), "the stale pidfile must be cleaned up" + ensure + Process.kill("KILL", sleeper) rescue nil + end + end + + def test_start_refuses_to_double_start_detached + with_tmp_global_config do |_dir| + command = Hive::Commands::Web.new + FileUtils.mkdir_p(File.dirname(command.pid_file)) + File.write(command.pid_file, command.pid_file_payload(Process.pid).to_yaml) + + _out, err = capture_io { command.send(:start_detached!) } + + assert_match(/already running/, err) + # No second pidfile write clobbered our live entry. + assert_equal Process.pid, YAML.safe_load_file(command.pid_file)["pid"] + ensure + FileUtils.rm_f(Hive::Commands::Web.new.pid_file) + end + end + + def test_install_drift_refusal_names_the_force_flag + with_tmp_global_config_and_home do |dir| + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + + installer = Hive::Commands::Web::ServiceInstaller.new( + host_os: "linux", + home: dir, + binary_path: hive, + systemctl_available: true, + runner: ->(_argv) { true } + ) + installer.install!(autostart: false) + unit = File.join(dir, ".config/systemd/user/hive-web.service") + File.write(unit, File.read(unit) + "# drift\n") + + command = Hive::Commands::Web.new + e = assert_raises(Hive::Error) { capture_io { command.install_service!(force: false) } } + + # install_service! builds its own installer anchored on the real HOME, + # so only assert the remediation contract, not the exact path. + assert_match(/--force/, e.message) + assert_match(%r{hive-web\.service}, e.message) + end + end + + private + + def process_alive?(pid) + Process.kill(0, pid) + true + rescue Errno::ESRCH + false + end + + def wait_until(timeout: 5) + deadline = Time.now + timeout + until yield + return false if Time.now > deadline + + sleep 0.05 + end + true + end + + def capture_io_json + out, _err = capture_io { yield } + JSON.parse(out) + end + + def ignore_failure + yield + rescue Hive::Error + # service_status raises when not running (daemon parity); the caller + # already has the emitted envelope. + end +end diff --git a/test/unit/config_web_test.rb b/test/unit/config_web_test.rb new file mode 100644 index 000000000..88b1561a4 --- /dev/null +++ b/test/unit/config_web_test.rb @@ -0,0 +1,73 @@ +require "test_helper" + +# Validation matrix for the local-mode web config keys (U1): +# `web.auth` (auto | none | github) and `web.allow_public_unsafe`. +class ConfigWebTest < Minitest::Test + include HiveTestHelper + + def validate(web_block) + cfg = { + "web" => { + "bind" => "127.0.0.1", + "port" => 4567, + "origin" => "http://127.0.0.1:4567", + "auth" => "auto", + "allow_public_unsafe" => false, + "github" => { "owner" => nil, "client_id" => "client" } + }.merge(web_block) + } + Hive::Config.send(:validate_web_config!, cfg, "/tmp/fake-config.yml") + end + + def test_defaults_are_loopback_auto_auth + defaults = Hive::Config::DEFAULTS.fetch("web") + + assert_equal "auto", defaults.fetch("auth") + assert_equal false, defaults.fetch("allow_public_unsafe") + assert_equal "127.0.0.1", defaults.fetch("bind") + end + + def test_valid_auth_modes_pass + %w[auto none github].each do |mode| + validate({ "auth" => mode }) + end + end + + def test_invalid_auth_mode_raises + e = assert_raises(Hive::ConfigError) { validate({ "auth" => "openid" }) } + + assert_match(/must be one of \["auto", "none", "github"\]/, e.message) + end + + def test_non_string_auth_mode_raises + e = assert_raises(Hive::ConfigError) { validate({ "auth" => true }) } + + assert_match(/web\.auth/, e.message) + end + + def test_allow_public_unsafe_boolean_passes + validate({ "allow_public_unsafe" => true }) + validate({ "allow_public_unsafe" => false }) + end + + def test_non_boolean_allow_public_unsafe_raises + e = assert_raises(Hive::ConfigError) { validate({ "allow_public_unsafe" => "yes" }) } + + assert_match(/web\.allow_public_unsafe .* must be a boolean/, e.message) + end + + # Existing global configs (Docker boxes included) predate both keys — a + # deep_merge of defaults must leave them valid with auth resolving to auto. + def test_config_without_new_keys_is_valid_after_merge + with_tmp_global_config do |dir| + File.write(File.join(dir, "config.yml"), { + "web" => { "bind" => "0.0.0.0", "origin" => "https://box.example.com" } + }.to_yaml) + + cfg = Hive::Config.load_global_web + + assert_equal "auto", cfg.fetch("auth") + assert_equal false, cfg.fetch("allow_public_unsafe") + end + end +end diff --git a/test/unit/daemon_guard_test.rb b/test/unit/daemon_guard_test.rb new file mode 100644 index 000000000..d40586bba --- /dev/null +++ b/test/unit/daemon_guard_test.rb @@ -0,0 +1,141 @@ +require "test_helper" +require "hive/setup/daemon_guard" + +# U5 — daemon binary/version consistency guard: match, drift, not-installed, +# stale pid, unsupported host, and the --force repair round-trip. +class DaemonGuardTest < Minitest::Test + include HiveTestHelper + + def test_matching_unit_and_live_pid_is_ok + with_sandbox do |dir, hive, guard| + install_daemon_unit(dir, hive) + write_live_pidfile(guard) + + result = guard.check + + assert_equal "ok", result.status + assert result.running + assert result.healthy? + end + end + + def test_unit_pointing_at_a_different_binary_is_drifted + with_sandbox do |dir, hive, guard| + install_daemon_unit(dir, "/usr/bin/hive") + + result = guard.check + + assert_equal "drifted", result.status + assert_equal "/usr/bin/hive", result.service_binary + assert_equal File.realpath(hive), result.expected_binary + assert_match(/daemon install --force/, result.detail) + end + end + + def test_drifted_unit_repairs_to_resolved_binary_and_recheck_passes + with_sandbox do |dir, hive, guard| + install_daemon_unit(dir, "/usr/bin/hive") + assert_equal "drifted", guard.check.status + + commands = [] + # Repair re-runs the daemon installer with --force; stub the runner so + # no real systemctl is touched and capture the outcome. + repairing = Hive::Setup::DaemonGuard.new( + home: dir, binary_path: hive, host_os: "linux", + runner: ->(argv) { commands << argv; true }, + systemctl_available: true + ) + outcome = nil + capture_io { outcome = repairing.repair! } + + assert outcome.success? + assert_includes commands, %w[systemctl --user daemon-reload] + assert_equal "ok", guard.check.status, "re-check passes after repair" + assert_equal File.realpath(hive), guard.check.service_binary + end + end + + def test_missing_unit_is_not_installed + with_sandbox do |_dir, _hive, guard| + result = guard.check + + assert_equal "not_installed", result.status + refute result.running + end + end + + def test_stale_pidfile_reports_not_running_but_ok_binary + with_sandbox do |dir, hive, guard| + install_daemon_unit(dir, hive) + FileUtils.mkdir_p(File.dirname(guard.pid_file)) + File.write(guard.pid_file, { "pid" => 2_147_000_000, "process_start_time" => nil, "_legacy" => true }.to_yaml) + + result = guard.check + + assert_equal "ok", result.status + refute result.running, "a stale/reused pid must not count as alive" + end + end + + def test_unsupported_host_reports_unsupported + with_tmp_dir do |dir| + hive = File.join(dir, "hive") + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + guard = Hive::Setup::DaemonGuard.new(home: dir, binary_path: hive, host_os: "mswin") + + result = guard.check + + assert_equal "unsupported", result.status + assert_match(/manually/, result.detail) + end + end + + def test_launchd_plist_binary_is_extracted + with_tmp_dir do |dir| + hive = File.join(dir, "hive") + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + guard = Hive::Setup::DaemonGuard.new(home: dir, binary_path: hive, host_os: "darwin23") + installer = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "darwin23", home: dir, binary_path: "/opt/other/hive" + ) + installer.install!(autostart: false) + + result = guard.check + + assert_equal "drifted", result.status + assert_equal "/opt/other/hive", result.service_binary + end + end + + private + + def with_sandbox + with_tmp_dir do |dir| + hive = File.join(dir, "bin", "hive") + FileUtils.mkdir_p(File.dirname(hive)) + File.write(hive, "#!/bin/sh\n") + FileUtils.chmod(0o755, hive) + # Sandbox the daemon pidfile location too (Paths.state_home derives + # from HIVE_HOME) so tests can't leak liveness into each other. + with_env("HIVE_HOME" => dir) do + guard = Hive::Setup::DaemonGuard.new(home: dir, binary_path: hive, host_os: "linux") + yield dir, hive, guard + end + end + end + + def install_daemon_unit(dir, binary) + installer = Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "linux", home: dir, binary_path: binary, systemctl_available: true, + runner: ->(_argv) { true } + ) + installer.install!(autostart: false) + end + + def write_live_pidfile(guard) + FileUtils.mkdir_p(File.dirname(guard.pid_file)) + File.write(guard.pid_file, guard.pid_file_payload(Process.pid).to_yaml) + end +end diff --git a/test/unit/setup_preflight_test.rb b/test/unit/setup_preflight_test.rb new file mode 100644 index 000000000..c798f5970 --- /dev/null +++ b/test/unit/setup_preflight_test.rb @@ -0,0 +1,127 @@ +require "test_helper" +require "hive/setup/preflight" + +# U3 — dependency preflight rows. All probes run through an injected runner +# so no real binary needs to exist in CI. +class SetupPreflightTest < Minitest::Test + include HiveTestHelper + + def test_all_present_reports_zero_warnings + preflight = preflight_with( + "ruby -v" => [ "ruby 3.4.1 (2024-12-25)\n", true ], + "git --version" => [ "git version 2.43.0\n", true ], + "bundle --version" => [ "Bundler version 2.5.0\n", true ], + "tmux -V" => [ "tmux 3.4\n", true ], + "node --version" => [ "v20.11.0\n", true ], + "npm --version" => [ "10.2.0\n", true ], + "claude --version" => [ "#{Hive::MIN_CLAUDE_VERSION} (Claude Code)\n", true ], + "codex --version" => [ "codex-cli 0.9.0\n", true ], + "gh --version" => [ "gh version 2.62.0\n", true ], + "gh auth status" => [ "", true ], + "sqlite3 --version" => [ "3.45.1 2024-01-15\n", true ] + ) + + capture_io { preflight.call } + + assert_empty preflight.warnings, "everything present ⇒ zero warnings" + assert_empty preflight.hard_failures + assert preflight.rows.all? { |row| row.status == "present" }, + -> { preflight.rows.map { |r| [ r.name, r.status ] }.inspect } + end + + def test_missing_codex_is_a_loud_warning_not_a_hard_failure + preflight = green_preflight({ codex: :missing }) + + capture_io { preflight.call } + + assert_includes preflight.warnings, "codex" + assert_empty preflight.hard_failures, "a missing agent CLI must not fail setup" + codex = preflight.rows.find { |row| row.name == "codex" } + assert_equal "missing", codex.status + assert_match(/codex login --device-auth/, codex.fix) + end + + def test_too_old_ruby_is_version_too_old_and_hard_fails + preflight = green_preflight({ "ruby -v" => [ "ruby 3.1.2 (2022)\n", true ] }) + + capture_io { preflight.call } + + ruby_row = preflight.rows.find { |row| row.name == "ruby" } + assert_equal "version_too_old", ruby_row.status + assert_match(/3\.1\.2 found, 3\.4\+ required/, ruby_row.message) + assert_includes preflight.hard_failures, "ruby" + end + + def test_unauthenticated_gh_names_the_exact_fix_command_without_attempting_login + commands = [] + preflight = green_preflight( + ({ "gh auth status" => [ "", false ] }), + spy: commands + ) + + capture_io { preflight.call } + + gh = preflight.rows.find { |row| row.name == "gh" } + assert_equal "present", gh.status, "the binary works; only credentials are missing" + assert_match(/NOT authenticated/, gh.message) + assert_match(/gh auth login/, gh.message) + # Diagnose-only contract: no login/auth command is ever spawned. + refute commands.any? { |argv| argv.join(" ") =~ /auth\s+login/ }, + "preflight must NEVER run `gh auth login` (or any auth flow)" + end + + def test_missing_qmd_offers_hive_owned_bootstrap_command + preflight = green_preflight({ qmd: :missing }) + + capture_io { preflight.call } + + qmd = preflight.rows.find { |row| row.name == "qmd" } + assert_equal "missing", qmd.status + assert_match(/@tobilu\/qmd/, qmd.fix) + refute_includes preflight.hard_failures, "qmd" + end + + private + + # A fully-green toolchain; per-test overrides replace individual probe + # results. Keys are full argv strings ("ruby -v"); `qmd` uses its finder + # seam instead of the runner (:missing or an executable path). + def green_preflight(overrides = {}, spy: nil) + # Symbol keys are binary names — expand to their probe argv. + overrides = overrides.to_h do |key, value| + key.is_a?(Symbol) && key != :qmd ? [ "#{key} --version", value ] : [ key, value ] + end + probes = { + "ruby -v" => [ "ruby 3.4.1 (2024-12-25)\n", true ], + "git --version" => [ "git version 2.43.0\n", true ], + "bundle --version" => [ "Bundler version 2.5.0\n", true ], + "tmux -V" => [ "tmux 3.4\n", true ], + "node --version" => [ "v20.11.0\n", true ], + "npm --version" => [ "10.2.0\n", true ], + "claude --version" => [ "#{Hive::MIN_CLAUDE_VERSION} (Claude Code)\n", true ], + "codex --version" => [ "codex-cli 0.9.0\n", true ], + "gh --version" => [ "gh version 2.62.0\n", true ], + "gh auth status" => [ "", true ], + "sqlite3 --version" => [ "3.45.1 2024-01-15\n", true ] + }.merge(overrides.except(:qmd)) + # :missing / nil entries mean "binary absent" — normalize them away so + # the runner's default missing-answer kicks in. + overrides.except(:qmd).each_key do |key| + next unless [ :missing, nil ].include?(probes[key]) + + probes.delete(key) + end + + runner = lambda do |argv| + spy << argv if spy + probes[argv.join(" ")] || [ "", false ] + end + qmd = overrides.key?(:qmd) ? overrides[:qmd] == :missing ? nil : "/fake/qmd/bin/qmd" : "/fake/qmd/bin/qmd" + + Hive::Setup::Preflight.new(runner: runner, qmd_finder: -> { qmd }) + end + + def preflight_with(table) + green_preflight(table) + end +end diff --git a/test/unit/web/app_provisioner_test.rb b/test/unit/web/app_provisioner_test.rb new file mode 100644 index 000000000..d37f5af90 --- /dev/null +++ b/test/unit/web/app_provisioner_test.rb @@ -0,0 +1,166 @@ +require "test_helper" +require "hive/web/app_provisioner" + +# U2 — version-matched web app provisioning for gem installs. The network +# seam (download callable), bundler runner, and XDG data home are all +# stubbed/sandboxed so these tests never touch the network or the real +# ~/.local/share. +class WebAppProvisionerTest < Minitest::Test + include HiveTestHelper + + def setup + @downloads = {} + @download_count = 0 + end + + def test_fresh_install_downloads_extracts_bundles_and_marks_provisioned + with_xdg_home do |dir| + provisioner = build_provisioner(dir) + + result = capture_io { provisioner.provision } + + target = provisioner.target_dir + assert_equal File.join(dir, "data", "hive", "web-app", Hive::VERSION), target + assert File.file?(File.join(target, "config", "application.rb")) + assert File.file?(File.join(target, ".hive-provisioned")) + # hive-cli path dependency rewritten to the installed gem's location. + gemfile = File.read(File.join(target, "Gemfile")) + assert_includes gemfile, %Q{gem "hive-cli", path: "#{Gem::Specification.find_by_name("hive-cli").full_gem_path}"} + refute_includes gemfile, 'path: ".."' + # bundle install ran with BUNDLE_PATH scoped inside the app dir. + assert_equal %w[bundle install], @runner_calls.last + assert_match(/#{Regexp.escape(File.join(target, ".bundle"))}/, @runner_envs.last.fetch("BUNDLE_PATH")) + assert result + end + end + + def test_rerun_is_idempotent_and_does_not_re_download + with_xdg_home do |dir| + provisioner = build_provisioner(dir) + capture_io { provisioner.provision } + downloads_before = @download_count + + second = build_provisioner(dir) + assert second.already_provisioned? + capture_io { assert_equal second.target_dir, second.provision } + + assert_equal downloads_before, @download_count, "re-run must not re-download" + end + end + + def test_version_bump_provisions_a_new_dir_and_leaves_the_old_one + with_xdg_home do |dir| + old = build_provisioner(dir, version: "0.0.1") + capture_io { old.provision } + + new = build_provisioner(dir, version: "9.9.9") + capture_io { new.provision } + + assert File.directory?(old.target_dir), "old version dir stays for rollback" + assert File.directory?(new.target_dir) + refute_equal old.target_dir, new.target_dir + end + end + + def test_checksum_mismatch_is_refused + with_xdg_home do |dir| + @downloads["bad-sha"] = "0" * 64 + provisioner = build_provisioner(dir, sha: "0" * 64) + + e = assert_raises(Hive::Error) { capture_io { provisioner.provision } } + + assert_match(/checksum verification/, e.message) + refute File.file?(File.join(provisioner.target_dir, "config", "application.rb")), + "a failed checksum must not leave an installed app behind" + end + end + + def test_network_failure_raises_typed_error_with_manual_instructions + with_xdg_home do |dir| + provisioner = build_provisioner(dir, fail_download: true) + + e = assert_raises(Hive::Error) { capture_io { provisioner.provision } } + + assert_match(/could not download/, e.message) + assert_match(/HIVEBOX_WEB_APP_DIR/, e.message) + end + end + + def test_tarball_with_single_top_level_dir_is_unwrapped + with_xdg_home do |dir| + # build_fixture_tarball nests everything under hive-web-/ + provisioner = build_provisioner(dir) + + capture_io { provisioner.provision } + + assert File.file?(File.join(provisioner.target_dir, "config", "application.rb")) + end + end + + def test_gem_without_hive_cli_dependency_cannot_be_rewritten + with_xdg_home do |dir| + @fixture_options = { gemfile_without_hive_cli: true } + provisioner = build_provisioner(dir) + + e = assert_raises(Hive::Error) { capture_io { provisioner.provision } } + + assert_match(/hive-cli path dependency/, e.message) + ensure + @fixture_options = nil + end + end + + private + + def fixture_tarball(version) + root = File.join(Dir.mktmpdir("hive-web-fixture"), "hive-web-#{version}") + FileUtils.mkdir_p(File.join(root, "config")) + FileUtils.mkdir_p(File.join(root, "bin")) + File.write(File.join(root, "config", "application.rb"), "# fixture\n") + File.write(File.join(root, "bin", "rails"), "#!/bin/sh\n") + if @fixture_options&.fetch(:gemfile_without_hive_cli, false) + File.write(File.join(root, "Gemfile"), "gem \"rails\"\n") + else + File.write(File.join(root, "Gemfile"), "gem \"rails\"\ngem \"hive-cli\", path: \"..\"\n") + end + tarball = File.join(File.dirname(root), "hive-web-#{version}.tar.gz") + system("tar", "-czf", tarball, "-C", File.dirname(root), "hive-web-#{version}", exception: true) + tarball + end + + def build_provisioner(xdg_root, version: Hive::VERSION, sha: nil, fail_download: false) + fixture = fixture_tarball(version) + digest = sha || Digest::SHA256.file(fixture).hexdigest + @runner_calls = [] + @runner_envs = [] + + downloader = lambda do |url, dest| + raise Net::HTTPError.new("offline", nil) if fail_download + + @download_count += 1 + if url.end_with?(".sha256") + File.write(dest, "#{digest} hive-web.tar.gz\n") + @downloads[:sha] = url + else + FileUtils.cp(fixture, dest) + @downloads[:tarball] = url + end + dest + end + + runner = lambda do |env, *argv| + @runner_calls << argv + @runner_envs << env + # Real extraction (hermetic — the archive is our fixture), but the + # bundler step is stubbed so no gems are installed. + argv[0] == "tar" ? system(*argv) : true + end + + Hive::Web::AppProvisioner.new( + version: version, + data_home: File.join(xdg_root, "data", "hive"), + download: downloader, + runner: runner + ) + end +end diff --git a/web/app/controllers/admin/daemon_controller.rb b/web/app/controllers/admin/daemon_controller.rb new file mode 100644 index 000000000..3a3cb7c7a --- /dev/null +++ b/web/app/controllers/admin/daemon_controller.rb @@ -0,0 +1,102 @@ +require "hive/setup/daemon_guard" + +# U5 — web-visible daemon health + one-click repair. Health reuses the same +# DaemonGuard the CLI setup flow uses (pidfile liveness + unit binary +# check); repair re-runs the daemon installer with --force in a BOUNDED +# subprocess so a hung systemctl can never wedge a Puma thread forever. +# +# Gating: this controller sits behind the application-wide require_login, +# which is a no-op only when the effective auth mode is `none` — and that +# mode is boot-refused on non-loopback binds. As defense in depth, repair +# additionally refuses when auth mode is none AND the request did not +# originate from a loopback address. +class Admin::DaemonController < ApplicationController + REPAIR_TIMEOUT_SEC = 120 + + def show + render json: health_payload + end + + def repair + unless repair_permitted? + return render json: { ok: false, error: "repair refused in no-auth mode off loopback" }, + status: :forbidden + end + + result = bounded_repair_subprocess + if result[:ok] + render json: { ok: true, guard: post_repair_health } + else + render json: { ok: false, error: result[:error] }, status: :internal_server_error + end + end + + private + + def guard + @guard ||= Hive::Setup::DaemonGuard.new + end + + def health_payload + result = guard.check + { + "ok" => result.healthy?, + "status" => result.status, + "running" => result.running, + "pid" => result.pid, + "unit_path" => result.unit_path, + "service_binary" => result.service_binary, + "expected_binary" => result.expected_binary, + "detail" => result.detail + } + end + + # Repair mutates a system service — never allow it implicitly. The CLI's + # U1 decision already refuses `none`-mode non-loopback binds; this check + # closes the residual case of an explicit unsafe-public no-auth box being + # reached from another machine. + def repair_permitted? + return true unless ENV["HIVEBOX_AUTH_MODE"] == "none" + + request.remote_ip.to_s =~ /\A(127\.|::1\z)/ ? true : false + end + + # Run `hive daemon install --force --json` as a child process with a hard + # wall clock. waitpid in a polling loop so Timeout semantics actually kill + # something (a blocking waitpid would ignore Ruby-level timeouts). + def bounded_repair_subprocess + hive_bin = guard.expected_binary + reader, writer = IO.pipe + pid = Process.spawn(hive_bin, "daemon", "install", "--force", "--json", + out: writer, err: writer) + writer.close + deadline = Time.now + REPAIR_TIMEOUT_SEC + status = nil + output = +"" + loop do + remaining = deadline - Time.now + if remaining <= 0 + Process.kill("KILL", pid) rescue nil + Process.wait(pid) rescue nil + return { ok: false, error: "daemon repair timed out after #{REPAIR_TIMEOUT_SEC}s" } + end + ready = IO.select([reader], nil, nil, [remaining, 0.5].min) + output << reader.read_nonblock(65_536) if ready && ready[0].include?(reader) + _, status = Process.waitpid2(pid, Process::WNOHANG) + break if status + end + reader.close + if status.success? + { ok: true, output: output } + else + { ok: false, error: "daemon repair exited #{status.exitstatus}: #{output.lines.last.to_s.strip}" } + end + rescue StandardError => e + { ok: false, error: "#{e.class}: #{e.message}" } + end + + def post_repair_health + @guard = nil # force a fresh probe after the subprocess rewrote the unit + health_payload + end +end diff --git a/web/app/controllers/application_controller.rb b/web/app/controllers/application_controller.rb index 2a1e5e28f..969684b79 100644 --- a/web/app/controllers/application_controller.rb +++ b/web/app/controllers/application_controller.rb @@ -50,6 +50,13 @@ class ApplicationController < ActionController::Base end def require_login + # Local (non-Docker) mode: `hive web` resolves the effective auth mode + # from web.auth + the bind address and exports it. `none` means a + # loopback-only UI with no sign-in — skip the gate entirely. The + # non-loopback + none combination is refused at boot by the CLI, so + # this env can only be "none" on a loopback bind in practice. + return if ENV["HIVEBOX_AUTH_MODE"] == "none" + return redirect_to login_path unless current_login # Sessions must track the CURRENT owner, not the owner at sign-in time: diff --git a/web/app/controllers/status_controller.rb b/web/app/controllers/status_controller.rb index c44440a83..65ba4172d 100644 --- a/web/app/controllers/status_controller.rb +++ b/web/app/controllers/status_controller.rb @@ -1,6 +1,24 @@ +require "hive/setup/daemon_guard" + class StatusController < ApplicationController def index @payload = StatusBroadcaster.snapshot @projects = @payload.fetch("projects", []) end + + private + + # Cheap, non-mutating probe for the U5 banner: pidfile liveness + unit + # binary comparison. Nil on any failure so a broken local install can + # never take the dashboard down (the banner simply does not render). + def daemon_banner_health + @daemon_banner_health ||= begin + Hive::Setup::DaemonGuard.new.check + rescue StandardError => e + Rails.logger.warn("daemon banner probe failed: #{e.class}: #{e.message}") + nil + end + end + + helper_method :daemon_banner_health end diff --git a/web/app/views/status/index.html.erb b/web/app/views/status/index.html.erb index 25bcdf793..67e22b65a 100644 --- a/web/app/views/status/index.html.erb +++ b/web/app/views/status/index.html.erb @@ -9,6 +9,28 @@ <% end %> <%= turbo_stream_from StatusBroadcaster::CHANNEL %> +<%# Local-mode daemon banner (U5): red strip with a Repair button when the + daemon is down (running unit, dead pidfile) or its service unit drifted + onto a different hive binary. Deliberately silent on not_installed / + unsupported hosts so the hivebox container and pre-install machines keep + today's clean dashboard. %> +<% if (health = daemon_banner_health) && (health.status == "drifted" || (health.status == "ok" && !health.running)) %> + +<% end %> + <%# TUI left-pane parity: the rail filters the grid client-side (buttons, not links — a navigation would discard the permanent composer's typed text). The controller wraps rail AND grid; it re-applies the filter diff --git a/web/config/routes.rb b/web/config/routes.rb index 659576df3..608f5497a 100644 --- a/web/config/routes.rb +++ b/web/config/routes.rb @@ -52,4 +52,10 @@ Rails.application.routes.draw do get "telegram" => "telegram#show", as: :telegram post "telegram" => "telegram#update", as: :update_telegram post "telegram/test" => "telegram#test", as: :test_telegram + + # Local-mode daemon health/repair (U5). Gated by require_login like every + # other page; repair additionally refuses no-auth non-loopback requests + # (defense in depth — the CLI already refuses to boot that combination). + get "admin/daemon" => "admin/daemon#show", as: :admin_daemon + post "admin/daemon/repair" => "admin/daemon#repair", as: :admin_daemon_repair end diff --git a/web/test/integration/admin_daemon_test.rb b/web/test/integration/admin_daemon_test.rb new file mode 100644 index 000000000..176e8815f --- /dev/null +++ b/web/test/integration/admin_daemon_test.rb @@ -0,0 +1,138 @@ +require "test_helper" +require "hive/setup/daemon_guard" + +# U5 — the web-visible daemon health/repair surface: the dashboard banner +# renders when the daemon is down/drifted, the JSON health endpoint reports +# guard status, and the repair POST is gated by the effective auth mode. +class AdminDaemonTest < ActionDispatch::IntegrationTest + setup do + create_hive_project! + configure_owner!(owner: "alice") + ENV.delete("HIVEBOX_AUTH_MODE") + # DaemonGuard anchors service-unit paths on HOME; sandbox it so tests + # never touch the developer's real LaunchAgents/systemd dirs. + @home_sandbox = Dir.mktmpdir("hive-web-home") + @old_home = ENV["HOME"] + ENV["HOME"] = @home_sandbox + end + + teardown do + ENV["HOME"] = @old_home + FileUtils.rm_rf(@home_sandbox) + FileUtils.rm_f(File.join(Hive::Paths.state_home, ".daemon.pid")) + end + + def install_daemon_unit(binary) + Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "linux", home: @home_sandbox, binary_path: binary, + systemctl_available: true, runner: ->(_argv) { true } + ).install!(autostart: false) + end + + def write_live_daemon_pidfile + path = File.join(Hive::Paths.state_home, ".daemon.pid") + FileUtils.mkdir_p(File.dirname(path)) + payload = { "pid" => Process.pid, "process_start_time" => Hive::Lock.send(:process_start_time, Process.pid) } + File.write(path, payload.to_yaml) + end + + test "health endpoint reports not_running when no daemon unit exists" do + sign_in! + get "/admin/daemon" + assert_response :success + body = response.parsed_body + assert_equal "not_installed", body["status"] + assert_equal false, body["running"] + assert body["detail"].present? + end + + test "health endpoint reports a drifted unit with both binaries" do + install_daemon_unit("/opt/other/hive") + sign_in! + get "/admin/daemon" + assert_response :success + body = response.parsed_body + assert_equal "drifted", body["status"] + assert_equal "/opt/other/hive", body["service_binary"] + assert body["expected_binary"].present? + end + + test "dashboard shows the banner with Repair on binary drift" do + install_daemon_unit("/opt/other/hive") + sign_in! + get "/" + assert_response :success + assert_match(/daemon-banner/, response.body) + assert_match(/Daemon binary drift/, response.body) + assert_match(admin_daemon_repair_path, response.body) + end + + def install_matching_daemon_unit + # Install a unit whose binary matches what the web-tier guard resolves + # as "this CLI" (InvokedBinary.path → PATH lookup fallback). + expected = Hive::Setup::DaemonGuard.new(home: @home_sandbox).expected_binary + Hive::Commands::Daemon::ServiceInstaller.new( + host_os: "linux", home: @home_sandbox, binary_path: expected, + systemctl_available: true, runner: ->(_argv) { true } + ).install!(autostart: false) + end + + test "dashboard shows the banner when the daemon is stopped" do + # A matching unit (status ok) but no live pidfile → down daemon. + install_matching_daemon_unit + + sign_in! + get "/" + assert_response :success + assert_match(/Daemon is not running/, response.body) + end + + test "dashboard hides the banner when the daemon is healthy" do + install_matching_daemon_unit + write_live_daemon_pidfile + + sign_in! + get "/" + assert_response :success + refute_match(/daemon-banner/, response.body) + end + + test "repair refuses in no-auth mode from a non-loopback request" do + # The CLI boot-refuses this combination (auth none + non-loopback bind); + # this pins the controller's defense-in-depth gate anyway. + ENV["HIVEBOX_AUTH_MODE"] = "none" + controller = Admin::DaemonController.new + remote = ActionDispatch::TestRequest.create( + "REMOTE_ADDR" => "203.0.113.7", "HTTP_X_FORWARDED_FOR" => "203.0.113.7" + ) + controller.set_request!(remote) + refute controller.send(:repair_permitted?), + "repair must refuse no-auth requests from non-loopback addresses" + + loopback = ActionDispatch::TestRequest.create("REMOTE_ADDR" => "127.0.0.1") + controller.set_request!(loopback) + assert controller.send(:repair_permitted?), "loopback no-auth repair stays allowed" + end + + test "repair invokes hive daemon install --force in a bounded subprocess" do + stub_hive = File.join(ENV["HIVE_TEST_HOME_ROOT"], "stub-hive-#{SecureRandom.hex(4)}") + log_file = "#{stub_hive}.log" + File.write(stub_hive, <<~SH) + #!/bin/sh + echo "$@" >> #{log_file} + echo '{"schema":"hive-daemon-install","ok":true,"outcome":"written"}' + exit 0 + SH + FileUtils.chmod(0o755, stub_hive) + + Hive::Setup::DaemonGuard.stub(:new, -> { Hive::Setup::DaemonGuard.new(binary_path: stub_hive, host_os: "unsupported") }) do + sign_in! + post "/admin/daemon/repair" + assert_response :success + assert_equal true, response.parsed_body["ok"], response.parsed_body.inspect + end + + assert_match(/daemon install --force --json/, File.read(log_file)), + "repair must invoke `hive daemon install --force`" + end +end diff --git a/web/test/integration/local_auth_mode_test.rb b/web/test/integration/local_auth_mode_test.rb new file mode 100644 index 000000000..2ba011ffb --- /dev/null +++ b/web/test/integration/local_auth_mode_test.rb @@ -0,0 +1,41 @@ +require "test_helper" + +# U1 — the Rails require_login gate honors HIVEBOX_AUTH_MODE=none (local +# loopback mode) while the default github gate stays active. +class LocalAuthModeTest < ActionDispatch::IntegrationTest + def with_auth_mode(mode) + old = ENV["HIVEBOX_AUTH_MODE"] + ENV["HIVEBOX_AUTH_MODE"] = mode + yield + ensure + old.nil? ? ENV.delete("HIVEBOX_AUTH_MODE") : ENV["HIVEBOX_AUTH_MODE"] = old + end + + test "auth mode none reaches the dashboard without a session" do + create_hive_project! + with_auth_mode("none") do + get "/" + assert_response :success + end + end + + test "default github gate still redirects an anonymous visitor" do + create_hive_project! + # No HIVEBOX_AUTH_MODE set — production parity: anonymous → login. + ENV.delete("HIVEBOX_AUTH_MODE") + configure_owner!(owner: "alice") + get "/" + assert_redirected_to login_path + end + + test "auth mode github still gates even when a stale local session exists" do + create_hive_project! + configure_owner!(owner: "alice") + with_auth_mode("github") do + # Dev/test seam signs in a NON-owner; the owner check must still fire. + get "/dev_login", params: { as: "mallory" } + get "/" + assert_redirected_to login_path + end + end +end diff --git a/wiki/commands/setup.md b/wiki/commands/setup.md new file mode 100644 index 000000000..3b1037388 --- /dev/null +++ b/wiki/commands/setup.md @@ -0,0 +1,68 @@ +--- +title: hive setup +type: command +created: 2026-08-21 +updated: 2026-08-21 +tags: [command, setup, web, daemon, local-mode] +see_also: [[commands/web]], [[commands/daemon]], [[commands/doctor]], [[dependencies]] +--- + +**TLDR**: `hive setup` provisions and validates the whole local (non-Docker) stack in one idempotent pass — dependency preflight, version-matched web app, binary-pinned daemon service, project enrollment, and a verified web launch — then prints `http://127.0.0.1:4567`. + +# hive setup + +## Usage + +``` +hive setup [PROJECT] [--json] [--doctor-only] + [--skip-preflight|--skip-web-app|--skip-daemon|--skip-enroll|--skip-web] + [--all] +``` + +## Steps (in order) + +1. **preflight** — `Hive::Setup::Preflight` reports Ruby 3.4, git, tmux, gh, + claude, codex, Node/npm, qmd, bundler, SQLite as + `present / missing / version_too_old`, each with the exact fix command. + External agent CLIs are diagnose-only: an unauthenticated `gh` is + detected via a read-only `gh auth status` probe; nothing is ever + installed or authenticated silently (R12). Only hard dependencies + (Ruby, git, bundler) fail the run. +2. **web_app** — resolves the Rails app: `HIVEBOX_WEB_APP_DIR` → source + checkout next to `lib/` → provisioned copy under + `${XDG_DATA_HOME:-~/.local/share}/hive/web-app/` ([[commands/web]] U2 + provisioning). +3. **daemon** — `Hive::Setup::DaemonGuard` compares the installed + `hive-daemon` unit's binary against the invoking CLI. Drift repairs via + the daemon installer's `--force` path (unit rewrite + restart). On hosts + without systemd-user/launchd the step degrades to "run `hive daemon start` + manually" instead of failing. +4. **enroll** — unregistered project → runs `hive init` (which defaults + `daemon.enabled: true`); initialized-but-disabled → flips it on via the + existing daemon enable machinery; already enrolled → no-op. `--all` + delegates to `hive daemon enable --all`. +5. **web** — starts the managed service (`hive web install/start` machinery) + and polls `GET /health?deep=1`. Port conflicts are reported with the + owning pid/command and FAIL — hive never kills listeners and never + silently picks another port. + +## Envelope + +`--json` emits `hive-setup.v1`: `{ok, url?, steps:[{name,status,detail,fix}]}`. +Exit code 0 only when every run step succeeded. + +## Notes + +- Idempotent: re-runs skip completed work (provisioned app marker file, + matching daemon unit, enrolled project, healthy web). +- `--doctor-only` prints just the preflight report (plus `hive-setup-doctor` + JSON under `--json`). +- Docker/hivebox is untouched by this command surface (R16). + +## Related decisions + +- The plan's scenario "`hive web --bind 0.0.0.0` with default auth ⇒ refusal" + was implemented as "resolves to github exactly as today": the hivebox + supervisor literally runs `hive web --bind 0.0.0.0` with default config, so + refusing there would break Docker (R16). The refusal fires when the + EFFECTIVE auth mode is `none` on a non-loopback bind. See wiki/gaps.md. diff --git a/wiki/commands/web.md b/wiki/commands/web.md index f6f253737..133f9784e 100644 --- a/wiki/commands/web.md +++ b/wiki/commands/web.md @@ -3,8 +3,8 @@ title: hive web type: command source: lib/hive/commands/web.rb, lib/hive/web/, web/, packaging/docker/, .github/workflows/release.yml created: 2026-06-04 -updated: 2026-06-25 -tags: [command, web, hivebox, rails, turbo] +updated: 2026-08-21 +tags: [command, web, hivebox, rails, turbo, local-mode] --- **TLDR**: `hive web` boots the hivebox web UI — a vanilla **Rails 8** app @@ -22,17 +22,59 @@ path with separate gates. ## CLI -`hive web [--bind] [--port]` (defaults from the `web:` config block). The -command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/` -next to `lib/`), exports `SECRET_KEY_BASE` (derived from the same persisted +`hive web [SUBCOMMAND] [--bind] [--port] [--json]` (defaults from the `web:` +config block). Subcommands (local mode, U4): + +- bare / `run` — foreground server (existing contract; no service needed). +- `install [--force]` — per-user autostart unit (`hive-web.service` under + systemd-user, `local.hive-web.plist` under LaunchAgents), a SEPARATE + service from the daemon. Same backup/force/unsupported-host mechanics as + the daemon installer. +- `start` / `stop` — drive the installed service; on hosts without a user + service manager they fall back to a detached pidfile-tracked process + (`/.web.pid`, logs in `/logs/web.log`). +- `status [--json]` — non-mutating report: running (pidfile or port probe), + `service_installed`, `service_enabled`, `unit_path`, `resolved_binary` + (`hive-web-status.v1` envelope). + +The command locates the Rails app (`HIVEBOX_WEB_APP_DIR` override, else `web/` +next to `lib/`, else provisioned copy — see Provisioning below), exports +`SECRET_KEY_BASE` (derived from the same persisted `Hive::Web::SessionSecret` file as before — sessions survive container recreation), `HIVEBOX_ORIGIN` (extra Action Cable origin allow; same-origin -host traffic is accepted without config), and +host traffic is accepted without config), `HIVEBOX_AUTH_MODE` (the resolved +effective auth mode — see Auth below), and `HIVEBOX_STORAGE_DIR` (the solid-stack sqlite files, under `Hive::Paths.state_home/web-storage` so they live on the `/data` mount), runs -`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container -or a source checkout the command exits 1 with guidance — the gem itself does -not package the Rails app (`test/unit/gemspec_test.rb` pins that). +`bin/rails db:prepare`, then execs `bin/rails server`. Outside the container, +a source checkout, or a provisioned install the command exits 1 with guidance +— the gem itself does not package the Rails app (`test/unit/gemspec_test.rb` +pins that). + +## Local provisioning (gem installs) + +When no app dir resolves, `Hive::Web::AppProvisioner` downloads +`hive-web-.tar.gz` (+ `.sha256`) from the GitHub release matching +`Hive::VERSION` into `${XDG_DATA_HOME:-~/.local/share}/hive/web-app/`, +verifies the checksum, extracts atomically, rewrites the embedded +`gem "hive-cli", path: ".."` to the installed gem's real path, and runs +`bundle install` with `BUNDLE_PATH` scoped inside the app dir. A marker file +(`.hive-provisioned`) makes re-runs idempotent; version bumps provision a new +dir and leave the old one for rollback. Failure is always a typed error naming +the `HIVEBOX_WEB_APP_DIR` escape hatch. The release job builds the tarball from +web/ (minus dev/test content) in the SAME job as the gem, so versions cannot +drift. + +## Auth modes (local vs Docker) + +`web.auth` selects the effective auth mode: `auto` (default) resolves at boot +to `none` on a loopback bind (`127.0.0.1` / `localhost` / `::1`) and to +`github` otherwise — hivebox binds `0.0.0.0` via its supervisor argv, so it +resolves to github exactly as before this key existed (R16). `none` skips the +login gate entirely (`HIVEBOX_AUTH_MODE=none` short-circuits `require_login`); +that combination is REFUSED at boot on a non-loopback bind unless +`--unsafe-public` or `web.allow_public_unsafe: true`. `github` forces the owner +gate regardless of bind. ## Auth diff --git a/wiki/dependencies.md b/wiki/dependencies.md index 6a8a6082b..11ec7e2ac 100644 --- a/wiki/dependencies.md +++ b/wiki/dependencies.md @@ -3,7 +3,7 @@ title: Dependencies type: dependencies source: Gemfile, hive.gemspec, Gemfile.lock, web/Gemfile, web/Gemfile.lock created: 2026-04-25 -updated: 2026-06-25 +updated: 2026-08-21 tags: [dependencies, gems, runtime] --- @@ -145,3 +145,15 @@ These are not gems but the CLI tools the runtime invokes: - [[modules/agent]] - [[commands/bot]] - [[e2e]] + +## Local web-mode toolchain (`hive setup` preflight, 2026-08-21) + +`hive setup` runs `Hive::Setup::Preflight`, which reports the local-mode +toolchain with present/missing/version_too_old rows plus exact fix +commands: Ruby 3.4, git, bundler (hard failures), tmux 3.2+, Node 18+ / +npm, qmd, sqlite3, and — diagnose-only, never installed or +authenticated by hive — `claude` (>= `Hive::MIN_CLAUDE_VERSION`), +`codex` (fix hint: `codex login --device-auth`), and `gh` (unauthenticated +detected via read-only `gh auth status`; fix hint: `gh auth login`). +Hive-owned items (qmd via npm, the web bundle via U2 provisioning) are +bootstrap-eligible; everything else is diagnose + fix-command only. diff --git a/wiki/gaps.md b/wiki/gaps.md index 2d71cc615..48e06228c 100644 --- a/wiki/gaps.md +++ b/wiki/gaps.md @@ -317,3 +317,18 @@ genuine clean verdict could fail to match and `:error`/retry (worst case emit the strict `## High/Medium/Nit` + `No findings.` format so the prose path is never exercised; until then, watch `reviews/errors-NN.md` tails for clean-but-rejected verdicts and extend `CLEAN_VERDICT` as new phrasings appear. + +## Local web install (2026-08-21) — plan scenario vs R16 conflict + +The add-local-hive-web-install plan's U1 test scenario 2 says +"`hive web --bind 0.0.0.0` with default auth ⇒ typed refusal". That +conflicts with R16 (Docker untouched) AND with the plan's own Approach +text ("hivebox binds 0.0.0.0, which resolves to github exactly as +today"), because `Hive::Web::Supervisor#run` literally spawns +`hive web --bind 0.0.0.0` inside the container with default config. +Implemented per the Approach text: `auto` + non-loopback → `github` +(no refusal); the typed refusal fires only when the EFFECTIVE auth mode +is `none` on a non-loopback bind without `--unsafe-public` / +`web.allow_public_unsafe`. If a hard refusal for public binds is ever +wanted, the hivebox supervisor must first switch to `web.auth: github` +in the container's generated config. diff --git a/wiki/index.md b/wiki/index.md index d59b93e36..0dc857cf3 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -3,7 +3,7 @@ title: hive Wiki type: index source: wiki/**/*.md created: 2026-05-14 -updated: 2026-06-25 +updated: 2026-08-21 tags: [index, wiki] --- @@ -42,6 +42,7 @@ Folder-as-agent workflow engine: a Ruby 3.4 / Thor CLI control plane where descr - [[commands/rebase-status]] — `wiki/commands/rebase-status.md` - [[commands/run]] — `wiki/commands/run.md` - [[commands/screenote]] — `wiki/commands/screenote.md` +- [[commands/setup]] — `wiki/commands/setup.md` - [[commands/stage_action]] — `wiki/commands/stage_action.md` - [[commands/status]] — `wiki/commands/status.md` - [[commands/tui]] — `wiki/commands/tui.md` diff --git a/wiki/log.d/20260821T000000Z-local-hive-web-install.md b/wiki/log.d/20260821T000000Z-local-hive-web-install.md new file mode 100644 index 000000000..f35383e35 --- /dev/null +++ b/wiki/log.d/20260821T000000Z-local-hive-web-install.md @@ -0,0 +1,55 @@ +--- +title: "log: first-class local (non-Docker) install/run mode for Hive web" +type: log-fragment +created: 2026-08-21 +tags: [web, setup, daemon, local-mode] +--- + +# 2026-08-21 — Local web install/run mode (`hive setup`, managed `hive web`) + +## What changed + +- **U1** — `web.auth` (`auto|none|github`) + `web.allow_public_unsafe` + config keys; `hive web` resolves the effective auth mode (`auto` ⇒ + `none` on loopback, `github` otherwise) and exports + `HIVEBOX_AUTH_MODE` to Rails; non-loopback binds with effective + `none` are refused unless `--unsafe-public`; Rails + `require_login` short-circuits in `none` mode. +- **U2** — `Hive::Web::AppProvisioner`: gem installs download the + version-matched `hive-web-.tar.gz` release asset (built in + the same release job as the gem), checksum-verify, extract atomically + under XDG data home, rewrite the embedded hive-cli path dep to the + installed gem, and bundle inside the app dir. Idempotent via marker. +- **U3** — `Hive::Setup::Preflight`: dependency report with + present/missing/version_too_old rows + exact fix commands; diagnose- + only for claude/codex/gh; hard failures limited to Ruby/git/bundle. +- **U4** — `hive web install|start|stop|status [--json]`: separate + `hive-web` systemd-user / launchd service via ServiceInstaller::Base; + detached pidfile fallback on hosts without a user service manager; + foreground `hive web` unchanged. +- **U5** — `Hive::Setup::DaemonGuard`: daemon unit binary vs CLI binary + drift check with one-command repair (daemon installer `--force`); + web `Admin::DaemonController` health/repair endpoints (bounded + subprocess, no-auth non-loopback refusal) and dashboard banner. +- **U6** — `hive setup [PROJECT]` enrollment: init-if-needed / + enable-if-disabled / no-op; composition over existing machinery only. +- **U7** — `hive setup` orchestrator: ordered idempotent steps, + `--skip-*`, `--doctor-only`, `--all`, `--json` (`hive-setup.v1`), + deep-health verification of `/health?deep=1`, truthful port-conflict + failure (never kills listeners, never moves ports). + +## Deviations + +- Plan scenario "`hive web --bind 0.0.0.0` default auth ⇒ refusal" was + NOT implemented literally: the hivebox supervisor runs exactly that + argv with default config, so it would break Docker (R16). Refusal is + for effective `none` + non-loopback instead. See [[gaps]]. + +## Tests + +Root suite additions: config_web_test, web_auth_test (commands), +app_provisioner_test, web service_installer_test, web_lifecycle_test, +daemon_guard_test, setup_preflight_test, setup_enroll_test (integration), +setup_orchestration_test (integration). Web suite addition: +local_auth_mode_test, admin_daemon_test (requires a runnable Rails +bundle; not executable in the local sandbox).