From b673b9645bc27f4f9ed3c7e0cb99bc4165347c1b Mon Sep 17 00:00:00 2001 From: release-bot Date: Mon, 13 Jul 2026 04:48:34 -0400 Subject: [PATCH 1/5] feat(cli): standardize and harden Hive interface --- .changeset/standardize-hive-cli.md | 5 + .github/workflows/ci.yaml | 3 + CHANGELOG.md | 15 + README.md | 35 ++- .../private/operations/cli-and-runbook.md | 144 +++++----- .../operations/prd-003-cli-migration.md | 125 ++++++++ ...2026-07-13-prd-003-hive-cli-adoption-qa.md | 144 ++++++++++ ...rd-003-hive-cli-adoption-security-audit.md | 133 +++++++++ package-lock.json | 10 + package.json | 4 +- scripts/verify-packed-cli.mjs | 83 ++++++ src/cli-commands.ts | 197 ++++++++++--- src/cli-interface.ts | 271 ++++++++++++++++++ src/cli-observability.ts | 168 +++++++++++ src/cli-update.ts | 107 +++++++ src/cli.ts | 42 +-- src/install/registry.ts | 199 +++++++++---- src/process-identity.ts | 74 +++++ src/service/commands.ts | 12 + src/service/daemon-wrapper.ts | 85 ++++++ src/service/index.ts | 80 +++++- src/service/templates.ts | 12 +- src/shared/apiary-root.ts | 11 +- src/terminal-safety.ts | 6 + tests/cli-commands.test.ts | 244 ++++++++++++++-- tests/cli-interface.test.ts | 217 ++++++++++++++ tests/cli-observability.test.ts | 221 ++++++++++++++ tests/cli-update.test.ts | 152 ++++++++++ .../daemon/installer/funnel-telemetry.test.ts | 3 + tests/dashboard/use-swr.test.tsx | 5 +- tests/install/registry.test.ts | 76 ++++- tests/process-identity.test.ts | 25 ++ tests/service/commands.test.ts | 19 ++ tests/service/daemon-wrapper.test.ts | 67 +++++ tests/service/helpers.ts | 6 + tests/service/service-module.test.ts | 52 +++- tests/service/templates.test.ts | 27 +- tests/shared/apiary-root.test.ts | 2 +- vitest.config.ts | 1 + 39 files changed, 2814 insertions(+), 268 deletions(-) create mode 100644 .changeset/standardize-hive-cli.md create mode 100644 library/knowledge/private/operations/prd-003-cli-migration.md create mode 100644 library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md create mode 100644 library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md create mode 100644 scripts/verify-packed-cli.mjs create mode 100644 src/cli-interface.ts create mode 100644 src/cli-observability.ts create mode 100644 src/cli-update.ts create mode 100644 src/process-identity.ts create mode 100644 src/service/daemon-wrapper.ts create mode 100644 src/terminal-safety.ts create mode 100644 tests/cli-interface.test.ts create mode 100644 tests/cli-observability.test.ts create mode 100644 tests/cli-update.test.ts create mode 100644 tests/process-identity.test.ts create mode 100644 tests/service/daemon-wrapper.test.ts diff --git a/.changeset/standardize-hive-cli.md b/.changeset/standardize-hive-cli.md new file mode 100644 index 0000000..08599b1 --- /dev/null +++ b/.changeset/standardize-hive-cli.md @@ -0,0 +1,5 @@ +--- +"@legioncodeinc/hive": minor +--- + +Standardize Hive on the Apiary CLI contract with branded help, canonical lifecycle verbs, JSON output, service logs, status, telemetry, and safe update support. `start` now controls the installed service; use `hive daemon` for foreground execution. The old `install-service` and `uninstall-service` spellings remain as deprecated aliases during the migration window. diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a957cd2..bd9b6dd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -86,6 +86,9 @@ jobs: - name: Build (tsc + esbuild bundles) run: npm run build + - name: Packed CLI conformance + run: npm run test:packed-cli + # Packaging sanity: prove `npm pack` assembles the publishable tarball # (the `files: ["dist"]` allowlist plus package.json/README/LICENSE) # without error on each OS. `--dry-run` builds the tarball in memory diff --git a/CHANGELOG.md b/CHANGELOG.md index 4447d71..7c9a935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## Unreleased — PRD-003 CLI interface adoption + +Hive now follows the shared Apiary CLI contract. The canonical operational surface is `start`, `stop`, `restart`, `install`, `uninstall`, `service-install`, `service-uninstall`, `update`, `status`, `register`, `logs`, and `telemetry`, with `--help`, `--version`, `--json`, and `--no-color`; `daemon` remains Hive's product-specific foreground command. + +This is an operator-visible semantic migration: + +- `hive start` now starts the already-installed OS service. Use `hive daemon` to run the portal in the foreground. +- `hive install` owns onboarding, service reconciliation, and Doctor registration. `hive service-install` owns only the OS service definition. +- `hive service-uninstall` removes only the service definition and preserves Hive state and Doctor registration. `hive uninstall` performs full Hive removal within Hive-owned boundaries. +- `install-service` and `uninstall-service` remain deprecated compatibility aliases for a migration window; primary help advertises only `service-install` and `service-uninstall`. +- Baseline commands support stable `--json` envelopes. Success/idempotent results exit `0`, runtime/service failures exit `1`, and usage/unknown-command failures exit `2`. +- `status`, Hive-isolated `logs`, and read-only `telemetry` provide the common Apiary observability surface. Help includes Hive-specific ASCII branding, `HIVE`, and the exact credit `Legion Code Inc. x Activeloop`. + +Automation that previously used `hive start` as a long-running foreground process must switch to `hive daemon`. Scripts should adopt the canonical service verb names, stop treating every nonzero result as the same failure class, and use `--json` instead of parsing human output. See [the migration note](library/knowledge/private/operations/prd-003-cli-migration.md). + ## v0.11.1 — 2026-07-13 Fixed search-result badges so hits from the live daemon correctly display their memory type (e.g. 'gotcha') instead of always falling back to 'fact'. diff --git a/README.md b/README.md index 3f7e804..accb9bd 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ cd hive npm install npm run build # tsc + esbuild → dist/cli.js -npm start # runs `node dist/cli.js start`, binds :3853 +node dist/cli.js daemon # foreground development process, binds :3853 npm run typecheck # tsc --noEmit npm test # vitest run ``` @@ -153,18 +153,35 @@ Open `http://127.0.0.1:3853` and the shell renders immediately, even on a cold b ## ⌨️ Using the CLI -The `hive` binary keeps a deliberately small surface. It's a portal daemon, not a Swiss Army knife: +The `hive` binary now follows the shared Apiary operational contract. Bare invocation and `--help` show Hive's ASCII identity, `HIVE`, the package version, grouped commands, and the exact credit `Legion Code Inc. x Activeloop`. ```bash -hive start # run the portal daemon on :3853 (the default verb) -hive stop # stop the portal daemon (service manager or direct SIGTERM) -hive install-service # install the OS service unit (launchd / systemd / schtasks) -hive uninstall-service # remove the service unit -hive uninstall # stop, remove service, registry entry, and hive state dir -hive register # append Hive to Doctor's daemon registry +hive start # start the already-installed OS service +hive stop # stop the installed OS service +hive restart # stop, start, and wait for Hive health +hive install # onboard Hive: reconcile the service and Doctor registration +hive uninstall # full product removal; only Hive-owned state is eligible +hive service-install # install or reconcile only the OS service definition +hive service-uninstall # remove only the OS service; preserve state and registration +hive update # update through Hive's approved package channel and verify health +hive status # show service, process, health, registration, version, and paths +hive register # idempotently upsert Hive in Doctor's registry +hive logs # tail only Hive's configured service logs +hive telemetry # read-only telemetry state and delivery-health summary +hive daemon # run Hive in the foreground on 127.0.0.1:3853 +hive --help # branded, grouped command reference +hive --version # exactly: hive v ``` -That's the whole list, on purpose. Day to day you never touch it; the installer wires the service unit and registration, Doctor keeps the process alive, and you live in the browser. +`start` no longer means "run the foreground process." It controls the installed service and fails with guidance to run `hive service-install` when no service exists. Use `hive daemon` for foreground development, containers, or direct process supervision. + +`install` and `service-install` intentionally own different transactions. `install` performs Hive onboarding and Doctor registration; `service-install` touches only the OS service definition. Likewise, `service-uninstall` preserves Hive state and Doctor registration, while `uninstall` performs the full product-removal transaction. The old `install-service` and `uninstall-service` spellings remain temporary deprecated aliases, but new scripts and documentation must use `service-install` and `service-uninstall`. + +Every baseline operational command accepts `--json`. JSON mode emits one stable object with `product`, `command`, `ok`, and `message` (plus command-specific details), one trailing newline, and no banner, color, credit, prompt, or extra prose. Success and already-satisfied idempotent operations exit `0`; service/runtime failures exit `1`; unknown commands and usage errors exit `2`. Automation should branch on the exit code and JSON fields rather than on a human sentence or stream. + +`logs` is hard-bound to Hive's own service identity and log destination. It defaults to the last 100 lines plus follow mode and supports `--lines `, `--no-follow`, and `--since `. `status` is a bounded snapshot and does not start Hive. Bare `telemetry` is read-only, reports the controlling opt-out setting and available delivery state, and never prints credentials. + +For the full migration and automation checklist, see [PRD-003 CLI migration](library/knowledge/private/operations/prd-003-cli-migration.md). Two behaviors worth knowing: diff --git a/library/knowledge/private/operations/cli-and-runbook.md b/library/knowledge/private/operations/cli-and-runbook.md index bc67b37..9f0b293 100644 --- a/library/knowledge/private/operations/cli-and-runbook.md +++ b/library/knowledge/private/operations/cli-and-runbook.md @@ -1,97 +1,93 @@ -# CLI And Runbook - -> Category: Operations | Version: 1.0 | Date: July 2026 | Status: Active | Author: Mario Aldayuz - -Read this if you run or operate hive: it documents the four CLI verbs, how argv is dispatched, the exit codes, the environment variables hive reads, and the day-to-day operator runbook (where the lock and pid live, how to inspect a running hive, and the common failure modes). - -**Related:** -- [on-disk-footprint.md](./on-disk-footprint.md) -- [../architecture/doctor-registration-and-lifecycle.md](../architecture/doctor-registration-and-lifecycle.md) -- [../architecture/system-overview.md](../architecture/system-overview.md) -- [../infrastructure/build-and-release.md](../infrastructure/build-and-release.md) -- [../telemetry/telemetry-egress.md](../telemetry/telemetry-egress.md) -- [../security/trust-boundaries.md](../security/trust-boundaries.md) ---- - -## The four verbs - -Hive's whole CLI is four verbs, dispatched in `src/cli.ts` and implemented in `src/cli-commands.ts`: - -``` -hive start # run the daemon on 127.0.0.1:3853 (the default verb) -hive install-service # write + start the OS unit, then register with doctor -hive uninstall-service # deregister + remove the OS unit (registry entry stays) -hive register # upsert hive's entry into doctor's registry, standalone +# CLI and runbook + +> Category: Operations | Version: 2.0 | Date: July 2026 | Status: Active | Author: Mario Aldayuz + +Hive follows the suite-wide Apiary CLI contract. The normative command meanings live in the shared PRD; [PRD-003 CLI migration](./prd-003-cli-migration.md) owns migration and automation guidance. This runbook covers Hive-specific operating details only. + +## Operator commands + +```text +hive start Start the installed Hive service +hive stop Stop it +hive restart Restart it and wait for http://127.0.0.1:3853/health +hive status Inspect installation, PID, health, registration, and paths +hive logs Tail the Hive-owned service log +hive install Install/reconcile the service and register Hive with Doctor +hive uninstall Confirm and remove Hive's service, registration, and owned state +hive service-install Install/reconcile only the OS service +hive service-uninstall Remove only the OS service; preserve state and registration +hive update Install the approved npm release and verify or roll back +hive register Upsert only Hive's Doctor registry entry +hive telemetry Inspect telemetry state without changing it +hive daemon Run the portal in the foreground ``` -`cli.ts` reads `process.argv[2]`, defaulting to `start` when none is given, and routes to the matching runner. Any unknown verb prints `Usage: hive ` to stderr and exits 1. The dispatcher wraps every runner in one try/catch: a `DaemonAlreadyRunningError` or any other `Error` is printed to stderr and sets exit code 1; a non-Error throw re-throws. There are no flags: host and port are hard-pinned constants with no argv or env override, which is a deliberate design decision, not a missing feature. +Bare invocation and `--help` show the full grouped reference. Use `--json` for automation. Exit `0` means success or an already-satisfied idempotent state, `1` means an operational failure, and `2` means invalid usage. Full uninstall requires an interactive confirmation or explicit `--yes`; `uninstall --json` requires `--yes`. -```mermaid -flowchart TD - argv["process.argv[2] (default: start)"] --> dispatch{"which verb?"} - dispatch -->|"start"| start["runStartCommand: acquire lock, bind, serve, record lifecycle"] - dispatch -->|"install-service"| install["runInstallServiceCommand: write unit, start it, register with doctor"] - dispatch -->|"uninstall-service"| uninstall["runUninstallServiceCommand: emit event, tear down unit"] - dispatch -->|"register"| register["runRegisterCommand: upsert doctor registry entry"] - dispatch -->|"unknown"| usage["print usage, exit 1"] -``` +The compatibility aliases `install-service` and `uninstall-service` still dispatch with a deprecation warning. Do not use them in new automation. -## `hive start` +## Service and process model -The default verb. `runStartCommand` calls `startHive`, which acquires the single-instance lock before binding the socket, then binds `127.0.0.1:3853` and serves the shell immediately. It prints `hive listening on http://127.0.0.1:3853`, then records lifecycle telemetry after the readiness line (`recordStartLifecycle`, which never rejects, so a telemetry failure cannot alter the exit code). It installs `SIGINT` and `SIGTERM` handlers that close the server, release the lock and pid, and exit 0. A second `hive start` while a live daemon holds the lock exits 1 with `hive is already running (pid N) and holds lock ...`. Returns exit code 0 on a clean start. +The service definition on launchd, systemd, and Windows Task Scheduler invokes the fixed internal `service-daemon` action. That wrapper launches `hive daemon` without a shell, forwards termination signals, and opens Hive's authoritative service log with symlink and regular-file checks. The foreground daemon owns the single-instance lock and PID file and binds only `127.0.0.1:3853`. -## `hive install-service` +`hive start` never falls back to a foreground process. If the service is absent, run `hive service-install` or the full `hive install` transaction. For local development, containers, or an external supervisor, run `hive daemon` (or `npm start`, which maps to that command). -`runInstallServiceCommand` resolves the per-platform service plan, writes the unit, and runs the manager commands, then upserts hive's registry entry with doctor and prints whether it created or updated the entry. Telemetry (`hive_installed`) fires only after the user-facing success and never alters the exit code. It returns 1 if the service install failed (a unit-file write error or a manager-command failure), 0 otherwise. The install begins by best-effort deregistering the pre-decision-#32 `thehive` legacy units so a re-run migrates rather than races. Full unit and registration detail is in [../architecture/doctor-registration-and-lifecycle.md](../architecture/doctor-registration-and-lifecycle.md). +## Paths -## `hive uninstall-service` +The root resolution chain is `APIARY_HOME`, then Linux `XDG_STATE_HOME/apiary` when explicitly configured, then `~/.apiary`. -`runUninstallServiceCommand` initiates the `hive_uninstalled` telemetry emit before teardown (fire-and-forget, awaited only so the bounded POST can flush before the process exits), then runs the manager's deregister command and removes the unit file, and prints the result. It returns the service result's exit code (0 on success, 1 if a deregister command reported an error). It deliberately does not remove hive's entry from doctor's registry; that asymmetry is a known, documented gap (see the runbook below and the lifecycle doc). +| Artifact | Default path | +|---|---| +| Hive state | `~/.apiary/hive/` | +| PID | `~/.apiary/hive/hive.pid` | +| Lock | `~/.apiary/hive/hive.lock` | +| Service log | `~/.apiary/hive/service.log` | +| Doctor registry | `~/.apiary/registry.json` | -## `hive register` +Hive mutates only its own registry entry and state directory. Registry writes use a bounded inter-process lock plus atomic temp-file rename and fail closed on malformed JSON, preserving other products' entries. Never repair the registry by deleting peer entries; use `hive register` after correcting the malformed document or lock condition. -`runRegisterCommand` upserts hive's registry entry into `~/.honeycomb/doctor.daemons.json` standalone, without touching the OS service, and prints whether it created or updated the entry. It always returns 0. Use it to (re)register hive with doctor on a box where the service is managed some other way, or to repair a hand-edited registry. +## Inspect a running Hive -## Exit codes, at a glance +Start with the CLI: -| Verb | 0 | 1 | -|---|---|---| -| `start` | clean start (or clean shutdown on signal) | lock held by a live daemon, or a bind error | -| `install-service` | unit written, started, and registered | unit-file write error, or a manager-command failure | -| `uninstall-service` | unit removed and deregistered | a deregister command reported an error | -| `register` | always | (never) | -| unknown verb | | always | - -Telemetry never contributes to an exit code: every emit helper resolves rather than rejects. - -## Environment variables +```text +hive status +hive logs --lines 100 --no-follow +hive telemetry +``` -Hive reads exactly two environment variables, both telemetry opt-outs, both honored by the single egress chokepoint (`src/telemetry/emit.ts`): +The local HTTP probes are also available: -- `HONEYCOMB_TELEMETRY=0` silences all telemetry. -- `DO_NOT_TRACK` (the cross-tool standard) silences all telemetry for any value other than empty or `0`. +```bash +curl -s http://127.0.0.1:3853/health +curl -s http://127.0.0.1:3853/api/fleet-status +curl -s http://127.0.0.1:3853/api/registered-services +``` -There is no environment variable for the host, the port, the doctor URLs, or any file path. Those are code constants. This is the operator surface in full; anything else you might expect to configure is either a code option (a test seam, not an operator knob) or simply pinned. Telemetry behavior is documented in [../telemetry/telemetry-egress.md](../telemetry/telemetry-egress.md). +`hive logs` is hard-bound to `service.log`, redacts recognized credentials in emitted text, and never alters the stored file. It follows by default; Ctrl+C ends follow mode cleanly. Use `--no-follow` in scripts. -## Runbook: inspect a running hive +## Common failures -The daemon binds `127.0.0.1:3853`. The cheapest liveness check is the machine-probe form of `/health`, which returns `{ status, uptimeMs, version }` when the `Accept` header does not ask for HTML: +- **Service is not installed.** Run `hive service-install` for service-only repair or `hive install` when Doctor registration should also be reconciled. +- **Restart does not become healthy.** `hive restart` exits `1` after its bounded health window. Inspect `hive status` and `hive logs --no-follow`; it never reports an unverified restart as success. +- **Update health fails.** Hive attempts an exact-version rollback and reports whether recovery succeeded. A failed rollback is a hard failure requiring manual package repair. +- **Registry is locked.** Another cooperating process is updating the shared registry. Retry after the bounded lock holder finishes; Hive fails instead of overwriting a concurrent update. A stale lock is reclaimed only after its recorded owner process is no longer alive. +- **Registry JSON is malformed.** Hive fails closed and does not replace the file. Preserve and repair the document, then rerun `hive register`. +- **`hive is already running (pid N)`.** A foreground daemon already owns the lock. If the PID is dead, the next daemon start reclaims the stale lock automatically. +- **Dashboard remains on `/buzzing`.** Doctor or a required peer is not healthy. Check `/api/fleet-status` and the peer's own status/logs. +- **Dashboard redirects to `/login`.** The Honeycomb authentication/setup probe is unauthenticated or failed closed. -```bash -curl -s http://127.0.0.1:3853/health # liveness JSON: status, uptimeMs, version -curl -s http://127.0.0.1:3853/api/fleet-status # doctor's coarse fleet projection, as hive sees it -curl -s http://127.0.0.1:3853/api/registered-services # the names doctor has registered -``` +## Environment controls -The running process identity lives in two files under `~/.honeycomb`: `hive.lock` (holds the PID, created with an exclusive flag) and `hive.pid` (a mirror the doctor registry entry's `pidPath` points at). Both are removed on a clean shutdown. To confirm which process is "the" hive, read `~/.honeycomb/hive.pid` and check that PID. The full on-disk catalog is [on-disk-footprint.md](./on-disk-footprint.md). +- `APIARY_HOME` selects an absolute fleet state root. +- Linux `XDG_STATE_HOME` supplies the fallback fleet root when explicitly set. +- `HONEYCOMB_TELEMETRY=0` or a nonzero/nonempty `DO_NOT_TRACK` opts out of telemetry. -Service logs, when hive runs as a launchd unit, are at `~/.honeycomb/hive/launchd.out.log` and `launchd.err.log`. systemd routes to the journal (`journalctl --user -u hive.service`). The Windows task runs headless. +Host, port, health endpoint, service identity, registry product name, and log source are fixed product constants and cannot be redirected through CLI arguments. -## Runbook: common failure modes +## Related -- **`hive is already running (pid N)` on start.** A live daemon holds the lock. If `N` is genuinely dead (crash, power loss) the next start reclaims the stale lock automatically; if the message persists, the PID is alive. This is PID-probe based, not flock-based, which is exactly what lets a crashed daemon's lock be reclaimed without manual cleanup. -- **Dashboard redirects to `/buzzing` and stays.** The fleet is not ready per doctor: either the supervisor is unreachable or a required peer (honeycomb) is not `ok`. Check `curl :3853/api/fleet-status`. Hive is serving correctly; it is honestly reporting a booting or degraded fleet. -- **Dashboard redirects to `/login`.** Honeycomb's `/setup/state` reports not-authenticated, or the auth fetch failed (which fails closed to `/login`). If honeycomb is up, log in via the CLI honeycomb prints; if honeycomb is down, expect `/buzzing` first. -- **Panels for one daemon are empty while others render.** That daemon is unreachable and the wire degraded its panels to empty states fail-soft. A dead nectar blanks only the Hive Graph surfaces; a dead honeycomb blanks the honeycomb-backed panels. Hive itself is healthy. -- **Doctor still probes hive after `uninstall-service`.** Expected: uninstall does not remove hive's registry entry. Doctor keeps probing `:3853/health` and reports hive unreachable. To make doctor forget hive, edit `~/.honeycomb/doctor.daemons.json` by hand and remove the `name: "hive"` entry. This is a known operational wart, documented in [../architecture/doctor-registration-and-lifecycle.md](../architecture/doctor-registration-and-lifecycle.md). -- **A stale bundle serves after an upgrade.** The shell, `app.js`, and `styles.css` are served `no-cache` precisely so an in-place rebuild is picked up on the next load; a hard refresh forces it. If the asset routes 404, the bundle was not built (or the install was stripped); rebuild with `npm run build`. +- [PRD-003 CLI migration](./prd-003-cli-migration.md) +- [On-disk footprint](./on-disk-footprint.md) +- [Doctor registration and lifecycle](../architecture/doctor-registration-and-lifecycle.md) +- [Telemetry egress](../telemetry/telemetry-egress.md) diff --git a/library/knowledge/private/operations/prd-003-cli-migration.md b/library/knowledge/private/operations/prd-003-cli-migration.md new file mode 100644 index 0000000..7595ae3 --- /dev/null +++ b/library/knowledge/private/operations/prd-003-cli-migration.md @@ -0,0 +1,125 @@ +# PRD-003 CLI migration + +> Category: Operations | Date: July 2026 | Status: Active | Audience: Hive operators and automation maintainers + +Hive adopted the shared Apiary CLI interface contract. This note is the migration boundary for operators who used Hive's earlier narrow dispatcher. It documents observable behavior; product internals remain owned by Hive. + +## Canonical surface + +| Group | Canonical commands | Ownership | +|---|---|---| +| Service lifecycle | `start`, `stop`, `restart`, `status`, `logs` | Operate or inspect the installed Hive service. | +| Installation | `install`, `uninstall`, `service-install`, `service-uninstall`, `update` | Separate product onboarding/removal from OS-service-only changes. | +| Fleet | `register` | Idempotently upsert Hive's own Doctor registry entry. | +| Diagnostics | `telemetry` | Read-only telemetry configuration and delivery summary. | +| Product commands | `daemon` | Run Hive directly in the foreground. | +| Global options | `--help`, `--version`, `--json`, `--no-color` | Shared presentation and machine-output controls. | + +Bare invocation and `--help` render Hive-specific ASCII art, the uppercase product name `HIVE`, package-derived version, usage, grouped commands, and the exact credit `Legion Code Inc. x Activeloop`. `--version` writes exactly `hive v` followed by one newline. + +## Breaking semantic changes + +### `start` versus `daemon` + +Before this migration, `hive start` was the foreground portal process and remained attached to the terminal. It now controls the already-installed OS service. If no service is installed, it fails and directs the operator to `hive service-install`; it does not silently launch a detached process. + +Use `hive daemon` when the process must remain in the foreground, including local development, containers, and an external supervisor that owns the process directly. + +Automation migration: + +```text +before: hive start +after, installed service: hive start +after, foreground process: hive daemon +``` + +The command text is unchanged for service startup but its precondition and process lifetime are different. Any script that waited on the old foreground `start` process must change to `daemon`. + +### `install` versus `service-install` + +`hive install` is the product onboarding transaction. It reconciles Hive's OS service and registers Hive with Doctor, reporting those phases as product setup. It is not an npm-global package installer. + +`hive service-install` owns only the OS service definition. It must not perform login, delete state, or register Hive with Doctor. This makes service repair safe when onboarding and registry state are already correct. + +The removal boundary is symmetrical: + +- `hive service-uninstall` stops and removes only the OS service definition. Hive state and Doctor registration remain intact. +- `hive uninstall` is the full Hive removal transaction. It stops Hive, deregisters Hive, removes the service, and removes only Hive-owned state selected by the retention policy. It must never remove shared credentials, another Doctor registry entry, or another product's `~/.apiary/` directory. + +### Renamed service verbs + +The primary spellings are noun-last: + +| Deprecated alias | Canonical command | +|---|---| +| `install-service` | `service-install` | +| `uninstall-service` | `service-uninstall` | + +The old spellings remain dispatchable as deprecated aliases for the migration window, but they are absent from primary help. New documentation and automation must use the canonical commands now; do not depend on the aliases remaining indefinitely. + +## Output and exit behavior + +Every baseline operational command accepts `--json`. The machine form emits exactly one JSON document with one trailing newline and at least these fields: + +```json +{ + "product": "hive", + "command": "status", + "ok": true, + "message": "Hive status" +} +``` + +Command-specific facts appear in additional fields. JSON mode emits no ANSI, banner, credit, prompt, spinner, or prose outside the document. For automation, prefer `--json` and parse fields rather than matching a human sentence. + +| Exit | Meaning | Automation response | +|---:|---|---| +| `0` | Success, informational result, or requested idempotent state already satisfied. | Continue; inspect JSON fields when the distinction matters. | +| `1` | Runtime, service-manager, update, health, log-read, or other operational failure. | Retry only when the underlying operation is safe; surface `message`. | +| `2` | Unknown command, malformed option, missing argument, or other usage error. | Fix the invocation; do not retry unchanged. | + +Human output is an operator surface, while unknown-command and usage diagnostics use stderr. Deprecated-alias warnings are also human diagnostics; automation should migrate and consume `--json` rather than parse streams or prose. + +## Observability commands + +### `status` + +`hive status` is a bounded, read-only snapshot. Human output reports product/version, service installation, process/PID, health, Doctor registration, known update state, and Hive config/log paths in a stable order. `status --json` returns the same facts structurally. A stopped but installed service is a successful query. Status never starts or restarts Hive. + +### `logs` + +`hive logs` is hard-bound to Hive's validated service identity and authoritative log destination; it cannot be redirected to another product or an arbitrary path through the standard command. + +Defaults and options: + +- last 100 lines, then follow; +- `--lines ` changes the initial tail; +- `--no-follow` returns after the bounded read; +- `--since ` filters older entries; +- Ctrl+C ends follow mode cleanly with exit `0`; +- missing or unreadable Hive logs produce a concise exit `1` failure; +- recognized authorization, bearer-token, API-key, and credential values are redacted in terminal output without modifying the stored log. + +JSON log requests are bounded so the command can emit one complete JSON document rather than an endless stream. + +### `telemetry` + +Bare `hive telemetry` is read-only. It reports enabled/opted-out state, the controlling setting, destination class, available queue/delivery health, and how to opt out. It never prints credentials and never starts or restarts Hive. Existing fleet/dashboard telemetry behavior is separate from this CLI status summary. + +## Operator checklist + +- Replace foreground `hive start` calls with `hive daemon`. +- Replace `install-service` and `uninstall-service` with the canonical service verbs. +- Decide whether each installer call means full onboarding (`install`) or service-only reconciliation (`service-install`). +- Treat exit `2` as an invocation defect and exit `1` as an operational defect. +- Add `--json` wherever output is parsed by a script. +- Use `logs --no-follow` for bounded automation; use follow mode only for interactive tails. +- Do not assume `service-uninstall` deregisters Hive or deletes Hive state. +- Snapshot or update any help/version assertions to include the shared Hive banner anatomy and exact credit. + +## Related + +- [CLI and runbook](./cli-and-runbook.md) — historical operational detail; where it conflicts with this migration note, the PRD-003 surface documented here is authoritative. +- [Doctor registration and lifecycle](../architecture/doctor-registration-and-lifecycle.md) +- [Telemetry egress](../telemetry/telemetry-egress.md) +- [On-disk footprint](./on-disk-footprint.md) diff --git a/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md b/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md new file mode 100644 index 0000000..77d7a5a --- /dev/null +++ b/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md @@ -0,0 +1,144 @@ +# QA Report: PRD-003 Hive CLI Adoption + +**Plan document:** `C:/Users/mario/GitHub/the-apiary/cli-kit/library/requirements/backlog/prd-003-apiary-cli-interface-standard/` +**Audit date:** 2026-07-13 +**Base branch:** `main` +**Head:** `legion/prd-003-cli-standard-hive` (dirty, unpushed working tree) +**Auditor:** quality-worker-bee + +## Summary + +The Hive-local PRD-003 adoption is implementation-complete and receives a final clean QA PASS after security's final PASS. Process identity now requires exact CLI-path plus adjacent `daemon` tokens and is revalidated immediately before every SIGTERM after service-manager stop; service-unit writes use no-follow descriptor checks before truncation. The full 834/834-test suite, packed-tarball conformance, and repeated live Windows `0.11.1` PID replacement all pass with no remaining finding. + +## Scorecard + +| Category | Status | Notes | +|---|---|---| +| Completeness | ✅ | All Hive-applicable implementation criteria have code, tests, documentation, and packed evidence | +| Correctness | ✅ | Lifecycle, registry, output, observability, update, and error behavior match the PRD | +| Alignment | ✅ | Canonical vocabulary, product command grouping, active runbook, and migration note agree | +| Gaps | ✅ | Required state/rendering goldens and JSON exception cases are covered | +| Detrimental | ✅ | Final security PASS; no open Critical, High, Medium, or QA finding | + +## Critical Issues (must fix) + +None. + +## Warnings (should fix) + +None. + +## Suggestions (consider improving) + +None. + +## Plan Item Traceability + +| ID | Hive verdict | Implementation location | Notes | +|---|---|---|---| +| AC-1 | Pass (Hive) | `src/cli-interface.ts:42-50` | All Hive-required commands plus globals are present | +| AC-2 | Pass (Hive) | `src/cli-interface.ts:112-270`; `src/cli-commands.ts` | Semantics, exits, streams, and help placement conform | +| AC-3 | Pass (Hive) | `src/cli-interface.ts:52-59`; packed conformance | Art, uppercase name, version, usage, groups, and exact credit | +| AC-4 | Pass (Hive) | `src/cli-observability.ts:139-165` | Fixed Hive product/service/root/path only | +| AC-5 | Pass (Hive) | `src/install/registry.ts:88-157,197-278` | Safe idempotent Hive upsert under owner-aware lock | +| AC-6 | Pass (Hive) | `tests/cli-interface.test.ts`; `scripts/verify-packed-cli.mjs` | Hive manifest and packed interface drift checks | +| AC-7 | Pass (Hive) | `src/cli-interface.ts:42-50` | `daemon` retained under Product commands | +| AC-8 | Pass (Hive) | `src/cli-interface.ts:117-270`; output tests | Human streams and machine-only JSON conform | +| AC-a1 | N/A | cli-kit | Shared-library criterion already delivered by cli-kit | +| AC-a2 | Pass | `src/cli-interface.ts:42-59` | Help is composed from shared plus Hive manifest | +| AC-a3 | Pass | `tests/cli-interface.test.ts:87-96` | Canonical names advertised; old names deprecated aliases | +| AC-a4 | Pass | `src/cli-interface.ts:140-270` | Exit 0/1/2 classes tested | +| AC-a5 | Pass | `src/cli-interface.ts:119-270` | Every baseline verb supports stable JSON envelope | +| AC-a6 | Pass | `src/cli-interface.ts:119-270`; JSON tests | One JSON document/newline; no ANSI/banner/credit/prompt | +| AC-a7 | Pass | `src/cli-interface.ts:42-50` | `daemon` dispatches under Product commands | +| AC-a8 | N/A | cli-kit | Shared validator criterion; Hive manifest passes it | +| AC-a9 | Pass (Hive) | `tests/cli-interface.test.ts`; `scripts/verify-packed-cli.mjs` | Bare/help/version/unknown/human/JSON matrix covered | +| AC-b1 | Pass | `src/cli-commands.ts`; `src/cli-update.ts` | All eight Hive lifecycle/install/update handlers bind correctly | +| AC-b2 | Pass (Hive) | `src/cli-commands.ts:196-211`; `src/install/registry.ts` | Hive register safely upserts its own entry | +| AC-b3 | Pass | `src/cli-commands.ts`; `src/service/index.ts`; `src/install/registry.ts` | Requested-state repeats and registration return success | +| AC-b4 | Pass | `src/cli-commands.ts:151-193` | Restart reports success only after bounded health | +| AC-b5 | Pass | `src/cli-commands.ts`; uninstall tests | Service-only removal preserves state/registry; full removal confirms | +| AC-b6 | Pass | `src/install/state-dir.ts`; `src/install/registry.ts` | Product-owned boundaries and peer registry entries preserved | +| AC-b7 | Pass | `src/cli-update.ts:50-104`; `tests/cli-update.test.ts` | Approved npm channel, versions, state, health, rollback/hard failure | +| AC-b8 | Pass (implementation) | `src/service/commands.ts`; service tests | Fixed argv for Windows/macOS/Linux; native CI configured | +| AC-b9 | Pass | `src/service/daemon-wrapper.ts`; `src/service/templates.ts`; `src/cli-observability.ts` | Every service action reaches the authoritative private Hive log | +| AC-b10 | Pass | `CHANGELOG.md`; `library/knowledge/private/operations/prd-003-cli-migration.md` | Renames, aliases, semantics, and automation effects documented | +| AC-c1 | Pass | `src/cli-observability.ts:38-71`; state goldens | Ordered human status and equivalent JSON | +| AC-c2 | Pass | `src/cli-observability.ts:139-165`; cli-kit | 100/follow defaults and all required options | +| AC-c3 | Pass | `src/cli-observability.ts:146-157` | Fixed identity and authoritative owned log path | +| AC-c4 | Pass | `tests/cli-observability.test.ts` | Arbitrary product/path selectors rejected | +| AC-c5 | Pass | `tests/cli-observability.test.ts` | Secrets redacted without stored-log mutation | +| AC-c6 | Pass | `src/cli-observability.ts:139-165`; tests | SIGINT exit 0; missing/unreadable exit 1 | +| AC-c7 | Pass | `src/cli-observability.ts:85-119`; tests | Read-only summary, enabled/opted-out, no credential output | +| AC-c8 | Pass | `src/cli-interface.ts:192-229`; observability tests | No lifecycle side effects | +| AC-c9 | Pass (Hive) | `tests/cli-observability.test.ts` | Named status/log/telemetry states in human and JSON modes | +| AC-d1 | Pass (Hive) | `src/cli-interface.ts:52-59`; rendering tests | Distinct ASCII Hive/colony art within width | +| AC-d2 | Pass (Hive) | `tests/cli-interface.test.ts`; packed conformance | Complete banner anatomy | +| AC-d3 | N/A | Honeycomb | Cross-product visual criterion | +| AC-d4 | Pass | `src/cli-interface.ts:52-59,124-132`; purity tests | Help has no side effects | +| AC-d5 | Pass | rendering matrix in `tests/cli-interface.test.ts` | 80/narrow/color-disabled output ANSI-free and stable | +| AC-d6 | Pass | JSON matrix | No banner or attribution prose in JSON | +| AC-d7 | Pass | packed conformance; `src/cli-interface.ts:135-137` | Exact package-derived `hive v\n` | +| AC-d8 | Pass | `tests/cli-interface.test.ts`; packed conformance | Standard JSON version envelope only | +| AC-d9 | Pass (Hive) | `tests/cli-interface.test.ts` | 80/narrow/color/bare/help/version goldens covered | +| AC-d10 | Pass (Hive) | help and packed assertions | Exact `Legion Code Inc. x Activeloop` | +| AC-e1 | N/A | Honeycomb | Not a Hive criterion | +| AC-e2 | N/A | Doctor | Not a Hive criterion | +| AC-e3 | Pass | `scripts/verify-packed-cli.mjs`; alias/unit tests | Installed tarball passes Hive matrix and deprecated aliases remain tested | +| AC-e4 | N/A | Nectar | Not a Hive criterion | +| AC-e5 | Pass | `CHANGELOG.md`; migration note; rewritten active runbook | Repository documentation is internally consistent | +| AC-e6 | Pending ship evidence | `.github/workflows/ci.yaml:47-99` | Three-OS test/build/packed job configured; no run claimed for unpushed tree | +| AC-e7 | N/A | suite-level repository | Fleet-wide four-package job is outside Hive-local adoption | +| AC-e8 | Pass (Hive) | handler tests, packed conformance, registry/log isolation tests | No silent stubs or wrong-product delegation | +| AC-e9 | Pass | shared command matrix plus Hive migration/runbook links | Normative suite semantics are linked consistently | +| AC-e10 | N/A | fleet releases | Publication criterion is outside local Hive implementation | + +## Verification Evidence + +| Gate | Result | +|---|---| +| Final security re-review | PASS; no open Critical, High, or Medium finding | +| Focused PRD-003 tests | PASS: 11 files, 119 tests | +| Full `npm test` | PASS on rerun: 97 files, 834 tests; first run had one unrelated onboarding timeout and its isolated rerun passed 6/6 | +| Test-stability delta | PASS: 10s Vitest ceiling, hidden-first visibility fixture, and hermetic unauthenticated funnel seam affect tests only; no production path changed | +| `npm run typecheck` | PASS | +| `npm run build` | PASS | +| `npm run test:packed-cli` | PASS: packed install and CLI conformance | +| `npm audit --audit-level=high` | PASS: 0 vulnerabilities | +| `git diff --check` | PASS (line-ending warnings only) | +| Native CI | Configured for Ubuntu/macOS/Windows; pending push/run evidence | + +## Live Dogfood Lifecycle Addendum + +The dogfood evidence matches the corrected lifecycle transaction: + +- `hive stop` terminated orphan PID `104964` rather than accepting the service manager's nominal stop as convergence. +- `hive install` reconciled and started the fixed service action; `/health` returned Hive `0.11.1` under PID `55128`. +- `hive restart` stopped the prior instance, started PID `127724`, and returned success only after `/health` again reported `0.11.1`. +- After identity and symlink hardening, a second live Windows restart safely replaced PID `123832` with healthy Hive `0.11.1` PID `110864`. +- Final live double-check replaced PID `110864` with healthy Hive `0.11.1` PID `116892` under the exact-token and post-stop identity revalidation code. +- Unit tests prove an orphan is signaled after a successful manager stop, a stuck orphan returns exit `1` after the exact configured bound, and restart never starts a replacement until the prior PID is gone. +- Final dogfood security verdict is **PASS**: exact tokenized identity, immediate pre-signal revalidation, and descriptor-based unit-file protection close both prior Medium findings. + +Final delta gates: local identity/lifecycle/service focused suite 3 files/54 tests passed; security focused suite 4 files/68 tests passed; full suite rerun 97 files/834 tests passed; typecheck, build, packed conformance, audit, and diff check passed. + +## Prior Blocker Closure + +| Prior blocker | Final evidence | Status | +|---|---|---| +| Registry lost update / malformed replacement | PID plus UUID owner token, bounded wait, dead-owner stale recovery, owner-checked release, fail-closed parsing, exclusive randomized temp files, preservation tests | Closed | +| Human failures on stdout | Bounded non-streaming output buffered and routed by exit code; success/failure stream tests | Closed | +| JSON exceptions escaping envelope | Confirmation, status, telemetry, logs, and handler exceptions return one JSON result with empty stderr | Closed | +| Missing goldens / packed matrix | Expanded rendering/state/output tests plus installed-tarball conformance script and CI step | Closed | +| Stale active runbook | Runbook fully describes canonical matrix, lifecycle boundaries, logs, registry locking, exits, JSON, and recovery | Closed | + +## Files Changed + +- `.github/workflows/ci.yaml` (M), adds packed CLI conformance to all three OS legs. +- `.changeset/standardize-hive-cli.md`, `CHANGELOG.md`, `README.md`, migration note, and active runbook (A/M), document the canonical interface and migration. +- `package.json`, `package-lock.json` (M), add cli-kit and packed-test script. +- `scripts/verify-packed-cli.mjs` (A), bounded installed-tarball conformance verification. +- CLI, lifecycle, registry, observability, updater, service wrapper/template, terminal, and path sources under `src/` (A/M), implement Hive adoption and security boundaries. +- Corresponding `tests/` files (A/M), include exact-token identity, post-stop revalidation, orphan convergence, bounded failure, and descriptor/symlink cases and contribute to the 834-test green suite. +- `vitest.config.ts` (M), raises the per-test timeout to 10 seconds for parallel CI load without changing runtime code. +- Security and quality reports under `library/qa/` (A), record final independent review evidence. diff --git a/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md b/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md new file mode 100644 index 0000000..a37768f --- /dev/null +++ b/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md @@ -0,0 +1,133 @@ +# Security Audit Report: PRD-003 Hive CLI Adoption + +**Audit date:** 2026-07-13 +**Auditor:** security-worker-bee +**Scope:** All uncommitted PRD-003 adoption files, with focused review of CLI dispatch, lifecycle/uninstall, service adapters/templates/wrapper, updater, registry, status/logs/telemetry, shared paths, tests, package metadata, and migration documentation +**Node requirement:** >=22 +**`npm audit` result:** 0 vulnerabilities +**OpenClaw bundle scan:** Not applicable; Hive ships no OpenClaw bundle/audit script +**CVE catalog:** Security Stinger catalog refreshed 2026-04-25 (current) + +## Executive Summary + +Scope note: Hive's native service managers, local registry, dashboard daemon, and npm updater are outside the Stinger's Hivemind-specific Deep Lake catalog; universal command execution, filesystem, credential, terminal, network-bound, and supply-chain controls were applied. Three High findings were fixed: service logs could follow a planted symlink, existing logs and registry replacement files could retain permissive modes, and untrusted service/environment text could inject terminal control sequences. Follow-up reviews removed launchd/systemd's direct log opening and closed the prior Medium registry finding with owner-aware locking plus fail-closed malformed-document handling. A quality report was produced before this final security re-review and is stale; quality must rerun after these changes. The PRD-003 security gate is **PASS**. + +## Scorecard + +| Category | Status | Findings | +|---|---|---:| +| Credential / telemetry secrecy | OK | 0 | +| Uninstall confirmation / path ownership | OK | 0 | +| Command / argv construction and npm version validation | OK | 0 | +| Service wrapper signals and log permissions | FAIL (fixed) | 2 High | +| Registry product isolation | OK | 1 Medium closed | +| Log binding / redaction / symlinks | FAIL (fixed) | 1 High | +| Terminal and JSON integrity | FAIL (fixed) | 1 High (shared with log/output findings) | +| Health/network bounds | OK | 0 | +| Dependencies / supply chain | OK | 0 | + +## Critical Findings + +None detected. + +## High Findings (fixed) + +- [x] **Symlink log clobber** `src/service/daemon-wrapper.ts:12-27,37-47`, `src/service/templates.ts:42-98` - The wrapper opened the fixed service log with append semantics before checking file identity, and launchd/systemd could bypass the wrapper by opening the same path themselves. `openOwnedLog` now rejects symlinks, uses `O_NOFOLLOW` where supported, requires a regular file, and fails before spawning; every service definition invokes the fixed `service-daemon` action and contains no native stdout/stderr path directive. +- [x] **Sensitive local file permissions** `src/service/daemon-wrapper.ts:18-25`, `src/install/registry.ts:62-68` - Creation mode alone does not repair pre-existing permissive files and registry temp files relied on umask. Service logs and registry replacement files now receive explicit `0600` enforcement (Windows continues to rely on the user's inherited ACL model). +- [x] **Terminal escape injection** `src/terminal-safety.ts:1-6`, `src/cli-interface.ts:117-122` - Service-manager errors, environment-derived paths, and injected command output could carry ANSI/OSC/control sequences to the human terminal. The CLI's human stdout/stderr boundary now strips terminal controls; JSON output remains untouched and safely escaped by `JSON.stringify`. + +## Medium Findings (fixed) + +- [x] **Registry lost-update / corrupt-document recovery** `src/install/registry.ts:84-159,197-278` - The earlier implementation could overwrite concurrent peer updates and treated malformed JSON as an empty registry. The complete read-validate-modify-temp-rename transaction now runs under a bounded, private owner-token lock; stale recovery requires both age and a dead/invalid recorded PID, release verifies the owner token, malformed documents fail without mutation, and exclusive randomized temp files prevent pre-planted symlink clobbering. + +## Low Findings + +None detected. + +## Boundary Review Evidence + +- `uninstall` requires interactive confirmation, and JSON uninstall requires explicit `--yes` (`src/cli-interface.ts:156-183`). State removal resolves only `/hive`, checks containment, and rejects a symlinked state directory (`src/install/state-dir.ts:33-49`). Registry deletion filters only entries whose name is exactly `hive`. +- Service-manager and updater execution use fixed executable/argv arrays through `execFile` with no shell (`src/service/index.ts:104-124`, `src/cli-update.ts:19-24`). Manager commands use constant unit/task identifiers. Windows XML, launchd plist, and systemd values are escaped/quoted before rendering. +- npm's approved target is accepted only when it matches the bounded version grammar, and it is passed as one fixed package-spec argv element (`src/cli-update.ts:9,28-40,63-79`). The updater has a 120-second process timeout, stops/restarts only an installed service, rolls back to the installed package version after failed health, and verifies recovery. +- Log reads are hard-bound to product `hive`, service `com.legioncode.hive`, and Hive's owned state root. cli-kit performs canonical `realpath` containment, option/content limits, secret redaction, and terminal-control removal; Hive exposes no arbitrary path/product selector (`src/cli-observability.ts:119-153`). +- SIGINT/SIGTERM handlers are removed on child completion, signals are forwarded without shell mediation, descriptors are closed once, and spawn errors fail closed (`src/service/daemon-wrapper.ts:49-82`). +- AC-b9 remains truthfully satisfied through a fixed indirection: launchd `ProgramArguments`, systemd `ExecStart`, and the Windows task action pin Node, Hive's installed CLI entrypoint, and the literal `service-daemon` command. That internal command alone resolves Hive's authoritative log destination from the pinned Apiary home and opens it through the no-follow/private-mode boundary. Template tests prove all three fixed actions and prove launchd/systemd contain no bypassing native log directives (`src/service/templates.ts:42-98,139-163`, `tests/service/templates.test.ts`). +- Human output is sanitized at the dispatch boundary. JSON uses a single `JSON.stringify` envelope and never mixes stderr/banner/credit into stdout. Log JSON is automatically bounded (`--no-follow`) and contains redacted lines. +- Non-streaming human commands buffer their bounded result and route success to stdout or operational failure to stderr. Confirmation, status, telemetry, logs, and handler exceptions are converted into one JSON envelope with empty stderr in JSON mode (`src/cli-interface.ts:117-270`). +- The packed conformance script uses `spawnSync` with fixed argv, `shell: false`, a 180-second timeout, a 2 MiB output cap, a basename-confined tarball path, and unconditional temp/tarball cleanup. CI runs it under read-only repository permissions after `npm ci`, tests, and build (`scripts/verify-packed-cli.mjs`, `.github/workflows/ci.yaml:89-90`). +- Telemetry summary reveals only opt-out state, controlling setting, destination class, and timestamps; the configured PostHog key is tested only for presence and is never returned (`src/cli-observability.ts:85-111`). +- Status and update health requests use one-second abort signals. Retry count is capped at 60 and retry delay at five seconds (`src/cli-observability.ts:42-62`, `src/cli-commands.ts:150-169`). +- `npm audit --audit-level=high` reports zero vulnerabilities. No committed `.env` file or hidden Unicode in recognized agent-rule files was detected. + +## Verification + +| Gate | Result | +|---|---| +| Focused PRD-003/security tests | PASS - 11 files, 106 tests | +| Follow-up service-definition security tests | PASS - 5 files, 55 tests | +| Final registry/output/packed security tests | PASS - 5 files, 55 tests | +| `npm run typecheck` | PASS | +| `npm run build` | PASS | +| `npm audit --audit-level=high` | PASS - 0 vulnerabilities | +| `git diff --check` | PASS | +| Full `npm test` (final security run) | 821/822 passed; one unrelated onboarding gate test hit its five-second timeout | +| `npm run test:packed-cli` | PASS - packed install and CLI conformance, with bounded fixed-argv subprocesses and cleanup | + +The single full-suite timeout did not touch the PRD-003 files or security remediations; focused and packed suites are deterministic and green. Quality must rerun after this final security pass because its existing report predates the owner-aware lock changes. + +## Files Changed by Security Review + +| File | Change | +|---|---| +| `src/terminal-safety.ts` | Added terminal control-sequence sanitizer. | +| `src/cli-interface.ts` | Sanitized all human output at the CLI boundary while preserving JSON. | +| `src/cli-commands.ts` | Hard-capped health retry count and delay. | +| `src/service/daemon-wrapper.ts` | Added no-follow regular-file log opening, explicit modes, and safe shared descriptor cleanup. | +| `src/install/registry.ts` | Added private owner-aware locking, dead-owner stale recovery, fail-closed parsing, and exclusive randomized temp files. | +| `tests/cli-interface.test.ts` | Added OSC injection regression coverage. | +| `tests/service/daemon-wrapper.test.ts` | Added permission repair and symlink rejection coverage. | +| `tests/install/registry.test.ts` | Added POSIX registry-mode coverage. | +| `scripts/verify-packed-cli.mjs` | Bounded fixed-argv subprocess time/output and retained unconditional cleanup. | +| `library/knowledge/private/operations/cli-and-runbook.md` | Corrected lock-holder and stale-recovery claims. | + +## Security Gate Verdict + +**PASS.** No Critical, High, or Medium security finding remains open in the Hive PRD-003 adoption. The final dogfood addendum below records and closes the lifecycle-convergence follow-ups. Quality must include this addendum in its rerun. + +## Dogfood Lifecycle Convergence Addendum - 2026-07-13 + +### Scope and verdict + +Reviewed only the live-Windows-dogfood orphan convergence delta in `src/cli-commands.ts`, Unix service reconciliation in `src/service/index.ts`, and their focused tests. Termination polling is bounded, restart/install do not continue after a nonzero stop result, and restart never starts a replacement while the prior PID remains live. Both same-user Medium follow-ups are closed: process identity is revalidated after service-manager stop immediately before signaling, and existing symlinked service-unit targets are refused. No Critical, High, or Medium regression remains. Verdict: **PASS**. + +### Medium findings + +- [x] **PID reuse / identity ambiguity** `src/cli-commands.ts:240-300`, `src/process-identity.ts:10-70` - Every live PID is now checked immediately before `SIGTERM`, including a second post-`service.stop()` check in the registered-service path. A cached pre-stop PID must still equal the reread PID, and the live process must independently pass the identity check. Linux reads NUL-delimited `/proc//cmdline`; Windows uses fixed-argv, non-shell PowerShell/CIM with a 10-second timeout; macOS uses fixed-argv `ps` with the same bound. All paths require an exact normalized CLI-entry argument immediately followed by `daemon`, and lookup/parse/command failures fail closed. Regression coverage rejects separated tokens and proves post-manager identity revalidation prevents signaling. +- [x] **Existing Unix unit symlink rewrite** `src/service/index.ts:141-157,381-388` - `install()` first refuses an existing `lstat`-identified symlink, and the production writer then opens the fixed unit path with `O_NOFOLLOW` where supported, verifies the opened descriptor is a regular file with `fstat`, and only then truncates/writes it. This closes both the existing-target clobber and the Unix check/write symlink race before any service-manager install command. `tests/service/service-module.test.ts` covers refusal and proves the manager is not invoked. + +### Race and bound analysis + +- `stopAttempts` is clamped to 1-100 and `stopDelayMs` to 0-1000 ms; defaults are 40 attempts and 50 ms. No unbounded wait or busy retry was introduced. +- PID state is reread before signaling. If it is absent/dead, stop converges without a signal. If `SIGTERM` throws or the PID remains alive, stop returns `1`. +- A service-manager failure returns `1` while the daemon is live. A manager-reported failure with no live Hive PID is treated as an already-stopped idempotent result. +- `restart` calls the shared stop transaction and returns before `service.start()` on any termination failure. Health verification remains separately capped and request-aborted. +- `install` and `service-install` stop an existing registered service before rewriting/re-enabling its definition. Removing the Unix early return ensures stale `start` actions are replaced by the fixed `service-daemon` action; manager execution remains fixed argv without a shell. + +### Addendum verification + +| Gate | Result | +|---|---| +| Lifecycle/service focused tests | PASS - initial pass: 3 files, 51 tests | +| Added stuck-orphan bound test | PASS - one SIGTERM, exact configured polls/delays, exit 1 | +| Added restart fail-closed test | PASS - replacement start not called while prior PID remains alive | +| `npm run typecheck` | PASS | +| `git diff --check` | PASS | + +No production code was changed by this dogfood security re-review; only focused regression tests and this report addendum were added. + +### Final closure re-review - 2026-07-13 + +- Focused identity, lifecycle, service-module, and template suites pass: 4 files, 68 tests. +- `npm run typecheck` and `git diff --check` pass. +- Live Windows evidence supplied by the implementation pass confirms both immediate identity checks during restart convergence from PID 110864 to healthy Hive 0.11.1 PID 116892. +- Both dogfood Mediums are closed. Final security verdict: **PASS** with no open Critical, High, or Medium finding. diff --git a/package-lock.json b/package-lock.json index 52a17be..b0fa32d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "AGPL-3.0-or-later", "dependencies": { "@hono/node-server": "^2.0.6", + "@legioncodeinc/cli-kit": "^0.3.0", "hono": "^4.12.27", "react": "18.3.1", "react-dom": "18.3.1", @@ -785,6 +786,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@legioncodeinc/cli-kit": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@legioncodeinc/cli-kit/-/cli-kit-0.3.0.tgz", + "integrity": "sha512-Jm/ruBdX3dbRzxFApC719J9w7jO58yBvF5aHfxT6BfSVy4SxnuAYbvceQvljCshpUyT6i8Uq8kDTDDC/+LI87w==", + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=22.5.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", diff --git a/package.json b/package.json index af45c83..749e6ad 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,13 @@ "build": "tsc && node esbuild.config.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", - "start": "node dist/cli.js start", + "test:packed-cli": "node scripts/verify-packed-cli.mjs", + "start": "node dist/cli.js daemon", "prepack": "npm run build" }, "dependencies": { "@hono/node-server": "^2.0.6", + "@legioncodeinc/cli-kit": "^0.3.0", "hono": "^4.12.27", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/scripts/verify-packed-cli.mjs b/scripts/verify-packed-cli.mjs new file mode 100644 index 0000000..8699547 --- /dev/null +++ b/scripts/verify-packed-cli.mjs @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import packageJson from "../package.json" with { type: "json" }; + +const root = resolve(import.meta.dirname, ".."); +const temp = mkdtempSync(join(tmpdir(), "hive-packed-cli-")); +const npmCli = process.env.npm_execpath; +let tarball; + +function run(executable, args, options = {}) { + return spawnSync(executable, args, { + cwd: root, + encoding: "utf8", + shell: false, + env: { ...process.env, APIARY_HOME: join(temp, "apiary"), ...options.env }, + timeout: 180_000, + maxBuffer: 2 * 1024 * 1024 + }); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function invoke(args, expectedStatus = 0, env = {}) { + const result = run(process.execPath, [packedCli, ...args], { env }); + assert(result.status === expectedStatus, `${args.join(" ") || ""}: expected ${expectedStatus}, got ${result.status}\n${result.stderr}`); + return result; +} + +let packedCli; +try { + assert(npmCli !== undefined, "npm_execpath is required; run via npm run test:packed-cli"); + const packed = run(process.execPath, [npmCli, "pack", "--silent"]); + assert(packed.status === 0, `npm pack failed: ${packed.error?.message ?? packed.stderr}`); + const filename = packed.stdout.trim().split(/\r?\n/).at(-1); + assert(filename?.endsWith(".tgz"), `npm pack did not return a tarball: ${packed.stdout}`); + tarball = join(root, basename(filename)); + + const installed = run(process.execPath, [npmCli, + "install", "--prefix", temp, tarball, "--ignore-scripts", "--silent" + ]); + assert(installed.status === 0, `packed install failed: ${installed.stderr}`); + packedCli = join(temp, "node_modules", "@legioncodeinc", "hive", "dist", "cli.js"); + + const bare = invoke([]).stdout; + const help = invoke(["--help"]).stdout; + assert(bare === help, "bare invocation and --help diverged"); + for (const required of [ + "HIVE", "Legion Code Inc. x Activeloop", "start", "stop", "restart", "install", "uninstall", + "service-install", "service-uninstall", "update", "status", "register", "logs", "telemetry", "daemon" + ]) assert(help.includes(required), `packed help missing ${required}`); + assert(!help.includes(" install-service"), "deprecated install-service alias leaked into primary help"); + + assert(invoke(["--version"]).stdout === `hive v${packageJson.version}\n`, "packed text version mismatch"); + const versionJson = JSON.parse(invoke(["--version", "--json"]).stdout); + assert(versionJson.version === packageJson.version && versionJson.ok === true, "packed JSON version mismatch"); + + const helpJsonText = invoke(["--help", "--json"]).stdout; + const helpJson = JSON.parse(helpJsonText); + assert(helpJson.product === "hive" && helpJson.command === "help", "packed JSON help envelope mismatch"); + assert(!helpJsonText.includes("Legion Code Inc."), "JSON help contains attribution prose"); + assert(!/\u001b/.test(helpJsonText), "JSON help contains ANSI"); + + const unknown = JSON.parse(invoke(["not-a-command", "--json"], 2).stdout); + assert(unknown.ok === false && unknown.command === "not-a-command", "packed unknown-command contract mismatch"); + + invoke(["status"]); + JSON.parse(invoke(["status", "--json"]).stdout); + invoke(["telemetry"], 0, { HONEYCOMB_TELEMETRY: "0" }); + const telemetry = JSON.parse(invoke(["telemetry", "--json"], 0, { HONEYCOMB_TELEMETRY: "0" }).stdout); + assert(telemetry.details.telemetry.state === "opted-out", "packed telemetry opt-out mismatch"); + invoke(["logs", "--no-follow"], 1); + const missingLog = JSON.parse(invoke(["logs", "--no-follow", "--json"], 1).stdout); + assert(missingLog.ok === false && missingLog.command === "logs", "packed missing-log JSON mismatch"); + + process.stdout.write("Packed Hive CLI conformance passed.\n"); +} finally { + rmSync(temp, { recursive: true, force: true }); + if (tarball !== undefined) rmSync(tarball, { force: true }); +} diff --git a/src/cli-commands.ts b/src/cli-commands.ts index fb3a64d..5a2f8ba 100644 --- a/src/cli-commands.ts +++ b/src/cli-commands.ts @@ -4,10 +4,10 @@ * dispatcher assigns to `process.exitCode`. * * Lifecycle telemetry firing points (all funnel through src/telemetry/emit.ts, all fail-soft): - * - `hive_installed` after a successful `install-service` (deduped once per machine). - * - `hive_uninstalled` on `uninstall-service`, initiated BEFORE teardown, fire-and-forget. - * - `hive_first_run` after the first successful `start` (deduped once per machine). - * - `hive_updated` on `start` when the persisted last-seen version differs (deduped per + * - `hive_installed` after a successful full `install` (deduped once per machine). + * - `hive_uninstalled` on full `uninstall`, initiated BEFORE teardown, fire-and-forget. + * - `hive_first_run` after the first successful foreground `daemon` start (deduped once per machine). + * - `hive_updated` on `daemon` when the persisted last-seen version differs (deduped per * version), which captures npm-reinstall upgrades without an updater. * A telemetry failure NEVER changes a verb's exit code: emit helpers resolve, never reject. */ @@ -22,6 +22,7 @@ import { import { hiveStateDirExists, removeHiveStateDir, type StateDirFs } from "./install/state-dir.js"; import { createServiceModule, type ServiceModule } from "./service/index.js"; import { isPidAlive, readPidFile } from "./lock.js"; +import { isHiveCliProcess } from "./process-identity.js"; import { resolveHivePidPath, type FleetRootDeps } from "./shared/apiary-root.js"; import { emitInstalled, @@ -29,6 +30,7 @@ import { recordStartLifecycle, type EmitDeps } from "./telemetry/emit.js"; +import { HIVE_REGISTRY_HEALTH_URL } from "./install/registry.js"; /** A sink for user-facing output lines (defaults to `process.stdout`). */ export type OutputWriter = (text: string) => void; @@ -37,14 +39,14 @@ const defaultOut: OutputWriter = (text) => { process.stdout.write(text); }; -/** Injectable deps for the `start` verb. */ -export interface StartCommandDeps { +/** Injectable deps for the product-specific foreground `daemon` verb. */ +export interface DaemonCommandDeps { readonly startOptions?: StartHiveOptions; readonly telemetry?: EmitDeps; readonly out?: OutputWriter; } -export async function runStartCommand(deps: StartCommandDeps = {}): Promise { +export async function runDaemonCommand(deps: DaemonCommandDeps = {}): Promise { const out = deps.out ?? defaultOut; const runtime = startHive(deps.startOptions); out(`hive listening on http://${runtime.host}:${runtime.port}\n`); @@ -68,7 +70,7 @@ export async function runStartCommand(deps: StartCommandDeps = {}): Promise { const out = deps.out ?? defaultOut; const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + if (await service.isRegistered()) { + const stopCode = await runStopCommand(cliEntryPath, { service, out }); + if (stopCode !== 0) return stopCode; + } const result = await service.install(); out(`${result.message}\n`); - if (!result.ok) return 1; + return result.ok ? 0 : 1; +} + +export async function runUninstallServiceCommand( + cliEntryPath: string, + deps: ServiceCommandDeps = {} +): Promise { + const out = deps.out ?? defaultOut; + const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + + const result = await service.uninstall(); + out(`${result.message}\n`); + // M-2 / b-AC-6: the current unit having already been absent is a friendly no-op, + // not a failure - only a genuine deregister error should flip the exit code. + return result.ok || result.alreadyAbsent ? 0 : 1; +} + +/** Full onboarding transaction: reconcile the OS service, register with Doctor, then report install. */ +export async function runInstallCommand( + cliEntryPath: string, + deps: ServiceCommandDeps = {} +): Promise { + const out = deps.out ?? defaultOut; + const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + if (await service.isRegistered()) { + const stopCode = await runStopCommand(cliEntryPath, { service, out }); + if (stopCode !== 0) return stopCode; + } + const serviceResult = await service.install(); + out(`${serviceResult.message}\n`); + if (!serviceResult.ok) return 1; const registration = registerHiveWithDoctor(deps.registry); out( @@ -92,30 +128,77 @@ export async function runInstallServiceCommand( ? `Updated existing hive registry entry at ${registration.registryPath}\n` : `Registered hive in ${registration.registryPath}\n` ); - - // Telemetry fires only AFTER the user-facing success; it never rejects and never alters the code. await emitInstalled(deps.telemetry); return 0; } -export async function runUninstallServiceCommand( +/** Start only the already-installed OS service; foreground execution belongs to `hive daemon`. */ +export async function runStartCommand( cliEntryPath: string, deps: ServiceCommandDeps = {} ): Promise { const out = deps.out ?? defaultOut; const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + const result = await service.start(); + out(`${result.message}\n`); + return result.ok ? 0 : 1; +} - // Initiated BEFORE teardown (fire-and-forget) so the event still gets out when teardown removes - // the install. The promise never rejects; it is awaited after teardown only to let the bounded - // POST flush before the process exits. - const uninstallTelemetry = emitUninstalled(deps.telemetry); +export type HealthFetch = ( + input: string, + init?: { readonly signal?: AbortSignal } +) => Promise<{ readonly ok: boolean; readonly status: number }>; + +export interface RestartCommandDeps extends StopCommandDeps { + readonly healthFetch?: HealthFetch; + readonly healthUrl?: string; + readonly healthAttempts?: number; + readonly healthDelayMs?: number; + readonly delay?: (milliseconds: number) => Promise; +} - const result = await service.uninstall(); - out(`${result.message}\n`); - await uninstallTelemetry; - // M-2 / b-AC-6: the current unit having already been absent is a friendly no-op, - // not a failure - only a genuine deregister error should flip the exit code. - return result.ok || result.alreadyAbsent ? 0 : 1; +export async function waitForHiveHealth(deps: RestartCommandDeps): Promise { + const fetchHealth = deps.healthFetch ?? ((input, init) => fetch(input, init)); + const attempts = Math.min(60, Math.max(1, Math.floor(deps.healthAttempts ?? 10))); + const delayMs = Math.min(5_000, Math.max(0, Math.floor(deps.healthDelayMs ?? 250))); + const delay = deps.delay ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetchHealth(deps.healthUrl ?? HIVE_REGISTRY_HEALTH_URL, { + signal: AbortSignal.timeout(1_000) + }); + if (response.ok) return true; + } catch { + // A bounded retry handles the service's normal boot window. + } + if (attempt + 1 < attempts) await delay(delayMs); + } + return false; +} + +/** Stop, start, and prove health before reporting success. */ +export async function runRestartCommand( + cliEntryPath: string, + deps: RestartCommandDeps = {} +): Promise { + const out = deps.out ?? defaultOut; + const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + if (!(await service.isRegistered())) { + out("hive service is not installed; run 'hive service-install' first.\n"); + return 1; + } + + const stopCode = await runStopCommand(cliEntryPath, { ...deps, service, out }); + if (stopCode !== 0) return stopCode; + const started = await service.start(); + out(`${started.message}\n`); + if (!started.ok) return 1; + if (!(await waitForHiveHealth(deps))) { + out("hive service restarted but did not become healthy before the timeout.\n"); + return 1; + } + out("hive service restarted and is healthy.\n"); + return 0; } /** Injectable deps for the `register` verb. */ @@ -143,6 +226,10 @@ export interface StopCommandDeps { readonly readPid?: (path: string) => number | null; readonly isPidAlive?: (pid: number) => boolean; readonly kill?: (pid: number, signal: NodeJS.Signals) => void; + readonly isOwnedHiveProcess?: (pid: number, cliEntryPath: string) => Promise; + readonly stopAttempts?: number; + readonly stopDelayMs?: number; + readonly delay?: (milliseconds: number) => Promise; readonly out?: OutputWriter; } @@ -152,18 +239,66 @@ function isHiveProcessRunning(deps: StopCommandDeps): boolean { return pid !== null && (deps.isPidAlive ?? isPidAlive)(pid); } +async function terminateHiveProcess( + cliEntryPath: string, + deps: StopCommandDeps, + out: OutputWriter, + previouslyVerifiedPid?: number | null +): Promise { + const pidPath = deps.pidPath ?? resolveHivePidPath(deps.fleetRoot); + const pid = (deps.readPid ?? readPidFile)(pidPath); + const pidIsAlive = deps.isPidAlive ?? isPidAlive; + if (pid === null || !pidIsAlive(pid)) return true; + + const identityVerified = (previouslyVerifiedPid === undefined || previouslyVerifiedPid === pid) && + await (deps.isOwnedHiveProcess ?? isHiveCliProcess)(pid, cliEntryPath); + if (!identityVerified) { + out(`Could not stop hive: pid ${pid} does not identify the expected hive daemon.\n`); + return false; + } + + try { + (deps.kill ?? ((targetPid, signal) => process.kill(targetPid, signal)))(pid, "SIGTERM"); + out(`Sent SIGTERM to hive (pid ${pid}).\n`); + } catch (error) { + out(`Could not stop hive: ${error instanceof Error ? error.message : "unknown error"}.\n`); + return false; + } + + const attempts = Math.min(100, Math.max(1, Math.floor(deps.stopAttempts ?? 40))); + const delayMs = Math.min(1_000, Math.max(0, Math.floor(deps.stopDelayMs ?? 50))); + const delay = deps.delay ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (!pidIsAlive(pid)) return true; + if (attempt + 1 < attempts) await delay(delayMs); + } + + out(`Could not stop hive: pid ${pid} remained running after SIGTERM.\n`); + return false; +} + export async function runStopCommand(cliEntryPath: string, deps: StopCommandDeps = {}): Promise { const out = deps.out ?? defaultOut; const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); if (await service.isRegistered()) { + const pidPath = deps.pidPath ?? resolveHivePidPath(deps.fleetRoot); + const candidatePid = (deps.readPid ?? readPidFile)(pidPath); + const candidateIsAlive = candidatePid !== null && (deps.isPidAlive ?? isPidAlive)(candidatePid); + const verifiedPid = candidateIsAlive && candidatePid !== null && + await (deps.isOwnedHiveProcess ?? isHiveCliProcess)(candidatePid, cliEntryPath) + ? candidatePid + : null; const result = await service.stop(); if (!result.ok && !isHiveProcessRunning(deps)) { out("hive is not running.\n"); return 0; } out(`${result.message}\n`); - return result.ok ? 0 : 1; + if (!result.ok) return 1; + if (!isHiveProcessRunning(deps)) return 0; + out("Service manager left the hive daemon running; stopping the owned hive pid.\n"); + return (await terminateHiveProcess(cliEntryPath, deps, out, verifiedPid)) ? 0 : 1; } if (!isHiveProcessRunning(deps)) { @@ -171,21 +306,7 @@ export async function runStopCommand(cliEntryPath: string, deps: StopCommandDeps return 0; } - const pidPath = deps.pidPath ?? resolveHivePidPath(deps.fleetRoot); - const pid = (deps.readPid ?? readPidFile)(pidPath); - if (pid === null) { - out("hive is not running.\n"); - return 0; - } - - try { - (deps.kill ?? ((targetPid, signal) => process.kill(targetPid, signal)))(pid, "SIGTERM"); - out(`Sent SIGTERM to hive (pid ${pid}).\n`); - return 0; - } catch (error) { - out(`Could not stop hive: ${error instanceof Error ? error.message : "unknown error"}.\n`); - return 1; - } + return (await terminateHiveProcess(cliEntryPath, deps, out)) ? 0 : 1; } /** Injectable deps for the `uninstall` verb. */ diff --git a/src/cli-interface.ts b/src/cli-interface.ts new file mode 100644 index 0000000..dd29b02 --- /dev/null +++ b/src/cli-interface.ts @@ -0,0 +1,271 @@ +import { + composeProductManifest, + confirm, + exitCodeFor, + formatStatus, + formatTelemetrySummary, + REFERENCE_PRODUCT_BRANDS, + renderProductBanner, + renderVersion, + renderVersionJson, + resolveCommand, + statusToJson, + telemetrySummaryToJson, + type CommandResult, + type ProductManifest +} from "@legioncodeinc/cli-kit"; + +import { + runDaemonCommand, + runInstallCommand, + runInstallServiceCommand, + runRegisterCommand, + runRestartCommand, + runStartCommand, + runStopCommand, + runUninstallCommand, + runUninstallServiceCommand, + type OutputWriter +} from "./cli-commands.js"; +import { + inspectHiveStatus, + inspectHiveTelemetry, + runLogsCommand, + type LogsDeps, + type StatusDeps, + type TelemetryDeps +} from "./cli-observability.js"; +import { runUpdateCommand } from "./cli-update.js"; +import { HIVE_VERSION } from "./shared/constants.js"; +import { sanitizeTerminalText } from "./terminal-safety.js"; + +export const HIVE_MANIFEST: ProductManifest = composeProductManifest("hive", [ + { + name: "daemon", + summary: "Run the Hive daemon in the foreground", + destructive: false, + idempotent: false, + json: true + } +]); + +export function renderHiveHelp(width = 80): string { + return `${renderProductBanner({ + brand: REFERENCE_PRODUCT_BRANDS.hive, + version: HIVE_VERSION, + manifest: HIVE_MANIFEST, + width + })}\n`; +} + +export type HiveCommandExecutor = ( + command: string, + args: readonly string[], + cliEntryPath: string, + out: OutputWriter +) => Promise; + +export interface HiveCliDeps { + readonly stdout?: OutputWriter; + readonly stderr?: OutputWriter; + readonly width?: number; + readonly execute?: HiveCommandExecutor; + readonly status?: StatusDeps; + readonly telemetry?: TelemetryDeps; + readonly logs?: LogsDeps; + readonly confirmRemoval?: (assumeYes: boolean) => Promise; +} + +function jsonLine(value: unknown): string { + return `${JSON.stringify(value)}\n`; +} + +function result(command: string, ok: boolean, message: string, details?: Record): CommandResult> { + return details === undefined + ? { product: "hive", command, ok, message } + : { product: "hive", command, ok, message, details }; +} + +async function executeHiveCommand( + command: string, + args: readonly string[], + cliEntryPath: string, + out: OutputWriter +): Promise { + switch (command) { + case "start": return runStartCommand(cliEntryPath, { out }); + case "stop": return runStopCommand(cliEntryPath, { out }); + case "restart": return runRestartCommand(cliEntryPath, { out }); + case "install": return runInstallCommand(cliEntryPath, { out }); + case "uninstall": return runUninstallCommand(cliEntryPath, { out }); + case "service-install": return runInstallServiceCommand(cliEntryPath, { out }); + case "service-uninstall": return runUninstallServiceCommand(cliEntryPath, { out }); + case "update": return runUpdateCommand(cliEntryPath, { out }); + case "register": return runRegisterCommand({ out }); + case "daemon": return runDaemonCommand({ out }); + default: + out(`No Hive handler is registered for ${command}.\n`); + return 1; + } +} + +export async function runHiveCli( + argv: readonly string[], + cliEntryPath: string, + deps: HiveCliDeps = {} +): Promise { + const rawStdout = deps.stdout ?? ((text: string) => process.stdout.write(text)); + const rawStderr = deps.stderr ?? ((text: string) => process.stderr.write(text)); + const json = argv.includes("--json"); + const stdout: OutputWriter = json ? rawStdout : (text) => rawStdout(sanitizeTerminalText(text)); + const stderr: OutputWriter = json ? rawStderr : (text) => rawStderr(sanitizeTerminalText(text)); + const withoutGlobals = argv.filter((arg) => arg !== "--json" && arg !== "--no-color"); + + if (withoutGlobals.length === 0 || withoutGlobals.includes("--help") || withoutGlobals.includes("-h")) { + if (json) { + stdout(jsonLine(result("help", true, "Hive command help", { + commands: HIVE_MANIFEST.commands.map(({ name, group, summary }) => ({ name, group, summary })) + }))); + } else { + stdout(renderHiveHelp(deps.width ?? process.stdout.columns ?? 80)); + } + return 0; + } + + if (withoutGlobals[0] === "--version") { + stdout(json ? renderVersionJson("hive", HIVE_VERSION) : renderVersion("hive", HIVE_VERSION)); + return 0; + } + + const input = withoutGlobals[0] ?? ""; + const resolution = resolveCommand(HIVE_MANIFEST, input); + if (!resolution.ok) { + if (json) stdout(jsonLine(result(input, false, resolution.message))); + else stderr(`${resolution.message}\n`); + return exitCodeFor("usage-error"); + } + + const command = resolution.canonicalName; + const commandArgs = withoutGlobals.slice(1); + if (json && !resolution.command.json) { + const message = `${command} does not support --json.`; + stdout(jsonLine(result(command, false, message))); + return exitCodeFor("usage-error"); + } + if (resolution.deprecatedAlias !== undefined && !json) { + stderr(`Warning: '${resolution.deprecatedAlias}' is deprecated; use '${command}'.\n`); + } + + if (command === "uninstall") { + const invalidArgs = commandArgs.filter((arg) => arg !== "--yes"); + if (invalidArgs.length > 0 || commandArgs.filter((arg) => arg === "--yes").length > 1) { + const message = "uninstall accepts only the --yes confirmation flag."; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("usage-error"); + } + const assumeYes = commandArgs.includes("--yes"); + if (json && !assumeYes) { + stdout(jsonLine(result(command, false, "uninstall --json requires --yes."))); + return exitCodeFor("usage-error"); + } + let accepted: boolean; + try { + accepted = await (deps.confirmRemoval ?? ((yes) => confirm( + "Remove the Hive service, Doctor registration, and Hive-owned state?", + { assumeYes: yes, default: false } + )))(assumeYes); + } catch (error) { + const message = error instanceof Error ? error.message : "uninstall confirmation failed"; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("runtime-error"); + } + if (!accepted) { + const message = "Hive uninstall cancelled; no changes were made."; + if (json) stdout(jsonLine(result(command, true, message))); + else stdout(`${message}\n`); + return 0; + } + } + + if (command === "status") { + if (commandArgs.length > 0) { + const message = "status does not accept positional arguments."; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("usage-error"); + } + try { + const status = await inspectHiveStatus(cliEntryPath, deps.status); + if (json) stdout(jsonLine(result(command, true, "Hive status", { status: statusToJson(status) }))); + else stdout(formatStatus(status)); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : "Hive status failed"; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("runtime-error"); + } + } + + if (command === "telemetry") { + if (commandArgs.length > 0) { + const message = "telemetry is read-only and does not accept arguments."; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("usage-error"); + } + try { + const telemetry = inspectHiveTelemetry(deps.telemetry); + if (json) stdout(jsonLine(result(command, true, "Hive telemetry status", { telemetry: telemetrySummaryToJson(telemetry) }))); + else stdout(formatTelemetrySummary(telemetry)); + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : "Hive telemetry status failed"; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("runtime-error"); + } + } + + if (command !== "logs" && command !== "uninstall" && commandArgs.length > 0) { + const message = `${command} does not accept positional arguments.`; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("usage-error"); + } + + const captured: string[] = []; + const capturedErrors: string[] = []; + const streamsOutput = !json && (command === "daemon" || command === "logs"); + const commandOut: OutputWriter = streamsOutput ? stdout : (text) => captured.push(text); + const commandErr: OutputWriter = json ? (text) => capturedErrors.push(text) : stderr; + let code: number; + try { + if (command === "logs") { + const logArgs = json && !commandArgs.includes("--no-follow") + ? [...commandArgs, "--no-follow"] + : commandArgs; + code = await runLogsCommand(logArgs, { ...deps.logs, out: commandOut, err: commandErr }); + } else { + const handlerArgs = command === "uninstall" ? [] : commandArgs; + code = await (deps.execute ?? executeHiveCommand)(command, handlerArgs, cliEntryPath, commandOut); + } + } catch (error) { + const message = error instanceof Error ? error.message : "unknown Hive runtime error"; + if (json) stdout(jsonLine(result(command, false, message))); + else stderr(`${message}\n`); + return exitCodeFor("runtime-error"); + } + + if (json) { + const message = [...captured, ...capturedErrors].join("").trim() || (code === 0 ? `${command} completed.` : `${command} failed.`); + const details = command === "logs" ? { lines: captured.join("").split(/\r?\n/).filter(Boolean) } : undefined; + stdout(jsonLine(result(command, code === 0, message, details))); + } else if (!streamsOutput) { + const output = captured.join(""); + (code === 0 ? stdout : stderr)(output); + } + return code; +} diff --git a/src/cli-observability.ts b/src/cli-observability.ts new file mode 100644 index 0000000..9d9543d --- /dev/null +++ b/src/cli-observability.ts @@ -0,0 +1,168 @@ +import { promises as fsPromises, watch as watchFile } from "node:fs"; + +import { + formatStatus, + formatTelemetrySummary, + parseLogTailOptions, + statusToJson, + tailProductLog, + telemetrySummaryToJson, + type LogFileSystem, + type ServiceStatus, + type TelemetrySummary +} from "@legioncodeinc/cli-kit"; + +import { registryContainsHiveEntry, HIVE_REGISTRY_HEALTH_URL, type RegistryUpsertOptions } from "./install/registry.js"; +import { isPidAlive, readPidFile } from "./lock.js"; +import { createServiceModule, type ServiceModule } from "./service/index.js"; +import { + resolveHivePidPath, + resolveHiveStateDir, + resolveServiceLogPaths, + type FleetRootDeps +} from "./shared/apiary-root.js"; +import { HIVE_VERSION } from "./shared/constants.js"; +import { ENV_DO_NOT_TRACK, ENV_TELEMETRY, isOptedOut, loadLedger, POSTHOG_KEY } from "./telemetry/emit.js"; +import type { HealthFetch, OutputWriter } from "./cli-commands.js"; + +export interface StatusDeps { + readonly service?: ServiceModule; + readonly fleetRoot?: FleetRootDeps; + readonly registry?: RegistryUpsertOptions; + readonly readPid?: (path: string) => number | null; + readonly pidAlive?: (pid: number) => boolean; + readonly healthFetch?: HealthFetch; + readonly healthUrl?: string; +} + +export async function inspectHiveStatus(cliEntryPath: string, deps: StatusDeps = {}): Promise { + const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + const pid = (deps.readPid ?? readPidFile)(resolveHivePidPath(deps.fleetRoot)); + const running = pid !== null && (deps.pidAlive ?? isPidAlive)(pid); + const healthUrl = deps.healthUrl ?? HIVE_REGISTRY_HEALTH_URL; + let health: ServiceStatus["health"] = { state: running ? "unknown" : "not-applicable", endpoint: healthUrl }; + if (running) { + try { + const response = await (deps.healthFetch ?? ((input, init) => fetch(input, init)))(healthUrl, { + signal: AbortSignal.timeout(1_000) + }); + health = { + state: response.ok ? "healthy" : "unhealthy", + endpoint: healthUrl, + result: `HTTP ${response.status}` + }; + } catch (error) { + health = { + state: "unhealthy", + endpoint: healthUrl, + result: error instanceof Error ? error.message : "request failed" + }; + } + } + const logs = resolveServiceLogPaths(deps.fleetRoot); + return { + product: "hive", + version: HIVE_VERSION, + installation: (await service.isRegistered()) ? "installed" : "not-installed", + process: pid === null ? { state: "stopped" } : { state: running ? "running" : "stopped", pid }, + health, + registration: registryContainsHiveEntry(deps.registry) ? "registered" : "unregistered", + paths: { config: resolveHiveStateDir(deps.fleetRoot), logs: logs.out } + }; +} + +export async function runStatusCommand( + cliEntryPath: string, + json: boolean, + out: OutputWriter, + deps: StatusDeps = {} +): Promise { + const status = await inspectHiveStatus(cliEntryPath, deps); + out(json ? `${JSON.stringify(statusToJson(status))}\n` : formatStatus(status)); + return 0; +} + +export interface TelemetryDeps { + readonly env?: NodeJS.ProcessEnv; + readonly fleetRoot?: FleetRootDeps; + readonly posthogKey?: string; +} + +export function inspectHiveTelemetry(deps: TelemetryDeps = {}): TelemetrySummary { + const env = deps.env ?? process.env; + const optedOut = isOptedOut(env); + const controllingSetting = + env[ENV_TELEMETRY] === "0" + ? `${ENV_TELEMETRY}=0` + : env[ENV_DO_NOT_TRACK] !== undefined && env[ENV_DO_NOT_TRACK] !== "" && env[ENV_DO_NOT_TRACK] !== "0" + ? ENV_DO_NOT_TRACK + : "default"; + const ledger = loadLedger(resolveHiveStateDir(deps.fleetRoot)); + const sends = Object.values(ledger.reported).sort(); + return { + state: optedOut ? "opted-out" : "enabled", + controllingSetting, + destination: optedOut ? "disabled" : (deps.posthogKey ?? POSTHOG_KEY) === "" ? "disabled" : "hosted", + lastSuccessfulSend: sends.at(-1), + optOutInstruction: `Set ${ENV_TELEMETRY}=0 or ${ENV_DO_NOT_TRACK}=1.` + }; +} + +export function runTelemetryCommand( + json: boolean, + out: OutputWriter, + deps: TelemetryDeps = {} +): number { + const summary = inspectHiveTelemetry(deps); + out(json ? `${JSON.stringify(telemetrySummaryToJson(summary))}\n` : formatTelemetrySummary(summary)); + return 0; +} + +function createNodeLogFs(): LogFileSystem { + return { + readFile: (path) => fsPromises.readFile(path, "utf8"), + realpath: (path) => fsPromises.realpath(path), + watch(path, onChange) { + const watcher = watchFile(path, () => onChange()); + return { close: () => watcher.close() }; + } + }; +} + +export interface LogsDeps { + readonly fleetRoot?: FleetRootDeps; + readonly fs?: LogFileSystem; + readonly signal?: AbortSignal; + readonly out?: OutputWriter; + readonly err?: OutputWriter; +} + +export async function runLogsCommand(argv: readonly string[], deps: LogsDeps = {}): Promise { + const parsed = parseLogTailOptions(argv); + const out = deps.out ?? ((text: string) => process.stdout.write(text)); + const err = deps.err ?? out; + if (!parsed.ok) { + err(`${parsed.error}\n`); + return 2; + } + const logPath = resolveServiceLogPaths(deps.fleetRoot).out; + const controller = deps.signal === undefined ? new AbortController() : undefined; + const onSigint = (): void => controller?.abort(); + if (controller !== undefined) process.once("SIGINT", onSigint); + const result = await tailProductLog({ + productId: "hive", + serviceId: "com.legioncode.hive", + source: { productId: "hive", serviceId: "com.legioncode.hive", root: resolveHiveStateDir(deps.fleetRoot), path: logPath }, + options: parsed.options, + fs: deps.fs ?? createNodeLogFs(), + write: out, + signal: deps.signal ?? controller?.signal + }).finally(() => { + if (controller !== undefined) process.off("SIGINT", onSigint); + }); + if (!result.ok) { + err(`${result.error}\n`); + return 1; + } + return 0; +} diff --git a/src/cli-update.ts b/src/cli-update.ts new file mode 100644 index 0000000..69313e9 --- /dev/null +++ b/src/cli-update.ts @@ -0,0 +1,107 @@ +import { execFile } from "node:child_process"; + +import { createServiceModule, type ServiceModule } from "./service/index.js"; +import { HIVE_VERSION } from "./shared/constants.js"; +import { waitForHiveHealth, type OutputWriter, type RestartCommandDeps } from "./cli-commands.js"; + +const PACKAGE_NAME = "@legioncodeinc/hive"; +const UPDATE_TIMEOUT_MS = 120_000; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +export interface UpdateExecResult { + readonly ok: boolean; + readonly stdout: string; + readonly stderr: string; +} + +export type UpdateExec = (executable: string, args: readonly string[]) => Promise; + +function createUpdateExec(): UpdateExec { + return (executable, args) => new Promise((resolve) => { + execFile(executable, [...args], { timeout: UPDATE_TIMEOUT_MS, shell: false }, (error, stdout, stderr) => { + resolve({ ok: error === null, stdout, stderr }); + }); + }); +} + +function npmExecutable(platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? "npm.cmd" : "npm"; +} + +function parseApprovedVersion(stdout: string): string | null { + let candidate = stdout.trim(); + try { + const parsed: unknown = JSON.parse(candidate); + if (typeof parsed === "string") candidate = parsed; + } catch { + // npm may return the version as plain text; validate it below either way. + } + return VERSION_PATTERN.test(candidate) ? candidate : null; +} + +export interface UpdateCommandDeps extends RestartCommandDeps { + readonly exec?: UpdateExec; + readonly platform?: NodeJS.Platform; + readonly service?: ServiceModule; + readonly installedVersion?: string; + readonly out?: OutputWriter; +} + +/** Update only from the package's approved npm latest channel, with rollback on failed health. */ +export async function runUpdateCommand(cliEntryPath: string, deps: UpdateCommandDeps = {}): Promise { + const out = deps.out ?? ((text: string) => process.stdout.write(text)); + const run = deps.exec ?? createUpdateExec(); + const npm = npmExecutable(deps.platform); + const installed = deps.installedVersion ?? HIVE_VERSION; + const lookup = await run(npm, ["view", PACKAGE_NAME, "version", "--json"]); + const target = lookup.ok ? parseApprovedVersion(lookup.stdout) : null; + if (target === null) { + out(`Could not resolve the approved Hive release version. Installed: ${installed}.\n`); + return 1; + } + out(`Installed: ${installed}; approved target: ${target}.\n`); + if (target === installed) { + out("hive is already up to date.\n"); + return 0; + } + + const service = deps.service ?? createServiceModule({ execPath: cliEntryPath }); + const wasInstalled = await service.isRegistered(); + if (wasInstalled) { + const stopped = await service.stop(); + if (!stopped.ok) { + out(`${stopped.message}\n`); + return 1; + } + } + + const update = await run(npm, ["install", "--global", `${PACKAGE_NAME}@${target}`]); + if (!update.ok) { + out(`Hive update to ${target} failed; existing state was preserved.\n`); + if (wasInstalled) await service.start(); + return 1; + } + + if (wasInstalled) { + const started = await service.start(); + if (started.ok && await waitForHiveHealth(deps)) { + out(`hive updated from ${installed} to ${target}; state was preserved and health verified.\n`); + return 0; + } + + out(`Hive ${target} failed post-update health verification; rolling back to ${installed}.\n`); + await service.stop(); + const rollback = await run(npm, ["install", "--global", `${PACKAGE_NAME}@${installed}`]); + const restarted = rollback.ok ? await service.start() : { ok: false, message: "rollback install failed" }; + const recovered = restarted.ok && await waitForHiveHealth(deps); + out(recovered + ? `Rollback to ${installed} completed; update failed.\n` + : `Rollback to ${installed} did not recover Hive; manual repair is required.\n`); + return 1; + } + + out(`hive updated from ${installed} to ${target}; state was preserved.\n`); + return 0; +} + +export const updateCommandInternals = { npmExecutable, parseApprovedVersion }; diff --git a/src/cli.ts b/src/cli.ts index 64e0c28..0b8f5f7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,48 +1,18 @@ #!/usr/bin/env node import { fileURLToPath } from "node:url"; -import { - runInstallServiceCommand, - runRegisterCommand, - runStartCommand, - runStopCommand, - runUninstallCommand, - runUninstallServiceCommand -} from "./cli-commands.js"; +import { runHiveCli } from "./cli-interface.js"; import { DaemonAlreadyRunningError } from "./errors.js"; - -function printUsage(): void { - process.stderr.write("Usage: hive \n"); -} +import { runServiceDaemon } from "./service/daemon-wrapper.js"; async function run(): Promise { - const command = process.argv[2] ?? "start"; const cliEntryPath = fileURLToPath(import.meta.url); try { - switch (command) { - case "start": - process.exitCode = await runStartCommand(); - return; - case "stop": - process.exitCode = await runStopCommand(cliEntryPath); - return; - case "install-service": - process.exitCode = await runInstallServiceCommand(cliEntryPath); - return; - case "uninstall-service": - process.exitCode = await runUninstallServiceCommand(cliEntryPath); - return; - case "uninstall": - process.exitCode = await runUninstallCommand(cliEntryPath); - return; - case "register": - process.exitCode = await runRegisterCommand(); - return; - default: - printUsage(); - process.exitCode = 1; - return; + if (process.argv[2] === "service-daemon") { + process.exitCode = await runServiceDaemon(cliEntryPath); + return; } + process.exitCode = await runHiveCli(process.argv.slice(2), cliEntryPath); } catch (error) { if (error instanceof DaemonAlreadyRunningError) { process.stderr.write(`${error.message}\n`); diff --git a/src/install/registry.ts b/src/install/registry.ts index 608bf83..bfd0d40 100644 --- a/src/install/registry.ts +++ b/src/install/registry.ts @@ -1,4 +1,5 @@ -import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { closeSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { dirname } from "node:path"; import { resolveFleetRegistryPath, resolveHiveRegistryPidPath } from "../shared/apiary-root.js"; @@ -24,6 +25,7 @@ export interface RegistryFs { writeFile(path: string, content: string): void; rename(from: string, to: string): void; removeFile(path: string): void; + withLock?(registryPath: string, operation: () => T): T; } export interface RegistryUpsertOptions { @@ -36,6 +38,13 @@ export interface RegistryUpsertResult { readonly updatedExistingEntry: boolean; } +export class RegistryDocumentError extends Error { + constructor(message: string) { + super(message); + this.name = "RegistryDocumentError"; + } +} + export type RegistryDaemonEntry = Record & { readonly name: string; readonly healthUrl: string; @@ -62,17 +71,91 @@ export function createNodeRegistryFs(): RegistryFs { mkdirSync(path, { recursive: true, mode: 0o700 }); }, writeFile(path: string, content: string): void { - writeFileSync(path, content, "utf8"); + const fd = openSync(path, "wx", 0o600); + try { + writeFileSync(fd, content, "utf8"); + fchmodSync(fd, 0o600); + } finally { + closeSync(fd); + } }, rename(from: string, to: string): void { renameSync(from, to); }, removeFile(path: string): void { rmSync(path, { force: true }); + }, + withLock(registryPath: string, operation: () => T): T { + const lockPath = `${registryPath}.lock`; + const deadline = Date.now() + 2_000; + const owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now() }; + const ownerText = JSON.stringify(owner); + let fd: number | undefined; + while (fd === undefined) { + try { + fd = openSync(lockPath, "wx", 0o600); + try { + writeFileSync(fd, ownerText, "utf8"); + fchmodSync(fd, 0o600); + } catch (error) { + closeSync(fd); + fd = undefined; + rmSync(lockPath, { force: true }); + throw error; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + const observed = readFileSync(lockPath, "utf8"); + const stat = lstatSync(lockPath); + if (Date.now() - stat.mtimeMs > 30_000 && lockOwnerIsDeadOrInvalid(observed)) { + // Recheck the owner token immediately before unlinking so a successor lock is not + // removed merely because this contender originally observed a stale predecessor. + if (readFileSync(lockPath, "utf8") === observed) rmSync(lockPath, { force: true }); + continue; + } + } catch (statError) { + if ((statError as NodeJS.ErrnoException).code === "ENOENT") continue; + throw statError; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for Doctor registry lock at ${lockPath}.`); + } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); + } + } + try { + return operation(); + } finally { + closeSync(fd); + try { + if (readFileSync(lockPath, "utf8") === ownerText) rmSync(lockPath, { force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } } }; } +function lockOwnerIsDeadOrInvalid(raw: string): boolean { + let pid: number; + try { + const parsed: unknown = JSON.parse(raw); + const candidate = (parsed as { pid?: unknown } | null)?.pid; + if (!Number.isSafeInteger(candidate) || (candidate as number) <= 0) return true; + pid = candidate as number; + } catch { + return true; + } + try { + process.kill(pid, 0); + return false; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "ESRCH"; + } +} + function asObject(value: unknown): Record | null { if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record; return null; @@ -82,19 +165,23 @@ function parseRegistryDocument(raw: string): ParsedRegistryDocument { let parsed: unknown; try { parsed = JSON.parse(raw); - } catch { - return { root: {}, daemons: [] }; + } catch (error) { + throw new RegistryDocumentError(`Doctor registry contains invalid JSON: ${error instanceof Error ? error.message : "parse failed"}.`); } const root = asObject(parsed); - if (root === null) return { root: {}, daemons: [] }; + if (root === null) throw new RegistryDocumentError("Doctor registry root must be a JSON object."); const rawDaemons = root["daemons"]; - const daemons = Array.isArray(rawDaemons) - ? rawDaemons - .map((entry) => asObject(entry)) - .filter((entry): entry is Record => entry !== null) - : []; + if (rawDaemons !== undefined && !Array.isArray(rawDaemons)) { + throw new RegistryDocumentError("Doctor registry daemons field must be an array."); + } + const daemons: Array> = []; + for (const entry of rawDaemons ?? []) { + const object = asObject(entry); + if (object === null) throw new RegistryDocumentError("Doctor registry daemon entries must be objects."); + daemons.push(object); + } return { root, daemons }; } @@ -122,40 +209,36 @@ function readRegistryDocument(path: string, fs: RegistryFs): ParsedRegistryDocum } function nextTempPath(registryPath: string): string { - return `${registryPath}.tmp-${process.pid}-${Date.now()}`; + return `${registryPath}.tmp-${process.pid}-${randomUUID()}`; +} + +function withRegistryLock(path: string, fs: RegistryFs, operation: () => T): T { + return fs.withLock === undefined ? operation() : fs.withLock(path, operation); } export function registerHiveWithDoctor(options: RegistryUpsertOptions = {}): RegistryUpsertResult { const registryPath = options.registryPath ?? resolveRegistryWritePath(); const fs = options.fs ?? createNodeRegistryFs(); - const parsed = readRegistryDocument(registryPath, fs); - const nextDaemons = [...parsed.daemons]; - const hiveEntry = buildHiveRegistryEntry(); - - const index = nextDaemons.findIndex((entry) => entry["name"] === HIVE_REGISTRY_NAME); - if (index >= 0) { - nextDaemons[index] = { ...nextDaemons[index], ...hiveEntry }; - } else { - nextDaemons.push(hiveEntry); - } - - const nextRoot: Record = { ...parsed.root, daemons: nextDaemons }; - const serialized = `${JSON.stringify(nextRoot, null, 2)}\n`; - const tempPath = nextTempPath(registryPath); - fs.mkdirp(dirname(registryPath)); - fs.writeFile(tempPath, serialized); - try { - fs.rename(tempPath, registryPath); - } catch (error) { - fs.removeFile(tempPath); - throw error; - } - - return { - registryPath, - updatedExistingEntry: index >= 0 - }; + return withRegistryLock(registryPath, fs, () => { + const parsed = readRegistryDocument(registryPath, fs); + const nextDaemons = [...parsed.daemons]; + const hiveEntry = buildHiveRegistryEntry(); + const index = nextDaemons.findIndex((entry) => entry["name"] === HIVE_REGISTRY_NAME); + if (index >= 0) nextDaemons[index] = { ...nextDaemons[index], ...hiveEntry }; + else nextDaemons.push(hiveEntry); + + const serialized = `${JSON.stringify({ ...parsed.root, daemons: nextDaemons }, null, 2)}\n`; + const tempPath = nextTempPath(registryPath); + fs.writeFile(tempPath, serialized); + try { + fs.rename(tempPath, registryPath); + } catch (error) { + fs.removeFile(tempPath); + throw error; + } + return { registryPath, updatedExistingEntry: index >= 0 }; + }); } export interface RegistryDeleteResult { @@ -169,33 +252,29 @@ function registryHasHiveEntry(path: string, fs: RegistryFs): boolean { } function deleteHiveEntryAtPath(path: string, fs: RegistryFs): boolean { - let parsed: ParsedRegistryDocument; try { - parsed = readRegistryDocument(path, fs); + fs.readFile(path); } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") return false; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } - - const index = parsed.daemons.findIndex((entry) => entry["name"] === HIVE_REGISTRY_NAME); - if (index < 0) return false; - - const nextDaemons = parsed.daemons.filter((_, entryIndex) => entryIndex !== index); - const nextRoot: Record = { ...parsed.root, daemons: nextDaemons }; - const serialized = `${JSON.stringify(nextRoot, null, 2)}\n`; - const tempPath = nextTempPath(path); - fs.mkdirp(dirname(path)); - fs.writeFile(tempPath, serialized); - try { - fs.rename(tempPath, path); - } catch (error) { - fs.removeFile(tempPath); - throw error; - } - - return true; + return withRegistryLock(path, fs, () => { + const parsed = readRegistryDocument(path, fs); + const index = parsed.daemons.findIndex((entry) => entry["name"] === HIVE_REGISTRY_NAME); + if (index < 0) return false; + const nextDaemons = parsed.daemons.filter((_, entryIndex) => entryIndex !== index); + const serialized = `${JSON.stringify({ ...parsed.root, daemons: nextDaemons }, null, 2)}\n`; + const tempPath = nextTempPath(path); + fs.writeFile(tempPath, serialized); + try { + fs.rename(tempPath, path); + } catch (error) { + fs.removeFile(tempPath); + throw error; + } + return true; + }); } export function deleteHiveFromDoctor(options: RegistryUpsertOptions = {}): RegistryDeleteResult { diff --git a/src/process-identity.ts b/src/process-identity.ts new file mode 100644 index 0000000..9d3fc93 --- /dev/null +++ b/src/process-identity.ts @@ -0,0 +1,74 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +function normalized(value: string): string { + const path = resolve(value).replaceAll("\\", "/"); + return process.platform === "win32" ? path.toLowerCase() : path; +} + +export function argvIdentifiesHive(argv: readonly string[], cliEntryPath: string): boolean { + const expected = normalized(cliEntryPath); + return argv.some((argument, index) => + normalized(argument) === expected && argv[index + 1] === "daemon" + ); +} + +export function commandLineIdentifiesHive(commandLine: string, cliEntryPath: string): boolean { + return argvIdentifiesHive(parseProcessCommandLine(commandLine), cliEntryPath); +} + +export function parseProcessCommandLine(commandLine: string): string[] { + const argv: string[] = []; + let current = ""; + let quoted = false; + for (let index = 0; index < commandLine.length; index += 1) { + const character = commandLine[index]; + if (character === '"') { + quoted = !quoted; + continue; + } + if (!quoted && /\s/u.test(character)) { + if (current !== "") { + argv.push(current); + current = ""; + } + continue; + } + current += character; + } + if (current !== "") argv.push(current); + return argv; +} + +function execFileText(command: string, args: readonly string[]): Promise { + return new Promise((resolveResult, reject) => { + execFile(command, [...args], { timeout: 10_000, windowsHide: true }, (error, stdout) => { + if (error !== null) reject(error); + else resolveResult(stdout); + }); + }); +} + +/** Fail-closed process identity check used before signaling a PID read from Hive state. */ +export async function isHiveCliProcess(pid: number, cliEntryPath: string): Promise { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + if (process.platform === "linux") { + const raw = await readFile(`/proc/${pid}/cmdline`, "utf8"); + return argvIdentifiesHive(raw.split("\0").filter(Boolean), cliEntryPath); + } + if (process.platform === "win32") { + const script = + `$p=Get-CimInstance Win32_Process|Where-Object -Property ProcessId -EQ ${pid};` + + `if($null -eq $p){exit 1};` + + `Write-Output $p.CommandLine`; + const commandLine = await execFileText("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script]); + return commandLineIdentifiesHive(commandLine, cliEntryPath); + } + const commandLine = await execFileText("ps", ["-p", String(pid), "-o", "command="]); + return commandLineIdentifiesHive(commandLine, cliEntryPath); + } catch { + return false; + } +} diff --git a/src/service/commands.ts b/src/service/commands.ts index 596952b..5167743 100644 --- a/src/service/commands.ts +++ b/src/service/commands.ts @@ -48,6 +48,18 @@ export function stopCommands(plan: ServicePlan, uid: number): readonly ServiceCo } } +/** Start an already-installed service without rewriting or re-registering its unit. */ +export function startCommands(plan: ServicePlan, uid: number): readonly ServiceCommand[] { + switch (plan.manager) { + case "launchd": + return [{ command: "launchctl", args: ["kickstart", launchdServiceTarget(plan, uid)] }]; + case "systemd": + return [{ command: "systemctl", args: ["--user", "start", SYSTEMD_UNIT_NAME] }]; + case "schtasks": + return [{ command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }]; + } +} + export function uninstallCommands(plan: ServicePlan, uid: number): readonly ServiceCommand[] { switch (plan.manager) { case "launchd": diff --git a/src/service/daemon-wrapper.ts b/src/service/daemon-wrapper.ts new file mode 100644 index 0000000..5a8af2d --- /dev/null +++ b/src/service/daemon-wrapper.ts @@ -0,0 +1,85 @@ +import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync } from "node:fs"; +import { dirname } from "node:path"; +import { spawn, type ChildProcess } from "node:child_process"; + +import { resolveServiceLogPaths, type FleetRootDeps } from "../shared/apiary-root.js"; + +export interface ServiceDaemonDeps { + readonly fleetRoot?: FleetRootDeps; + readonly spawnChild?: typeof spawn; +} + +function openOwnedLog(path: string): number { + try { + if (lstatSync(path).isSymbolicLink()) throw new Error("refusing symlinked service log"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const fd = openSync(path, constants.O_APPEND | constants.O_CREAT | constants.O_WRONLY | noFollow, 0o600); + if (!fstatSync(fd).isFile()) { + closeSync(fd); + throw new Error("refusing non-file service log"); + } + chmodSync(path, 0o600); + return fd; +} + +/** + * Run the foreground daemon as a fixed-argv child whose output is bound to Hive's owned log files. + * This gives Windows Task Scheduler the same authoritative logs launchd/systemd expose without a + * shell or user-controlled interpolation. The wrapper forwards termination signals to the child. + */ +export async function runServiceDaemon( + cliEntryPath: string, + deps: ServiceDaemonDeps = {} +): Promise { + const logs = resolveServiceLogPaths(deps.fleetRoot); + mkdirSync(dirname(logs.out), { recursive: true, mode: 0o700 }); + let stdoutFd: number; + let stderrFd: number; + try { + stdoutFd = openOwnedLog(logs.out); + stderrFd = logs.err === logs.out ? stdoutFd : openOwnedLog(logs.err); + } catch { + return 1; + } + + let child: ChildProcess; + try { + child = (deps.spawnChild ?? spawn)(process.execPath, [cliEntryPath, "daemon"], { + env: process.env, + shell: false, + stdio: ["ignore", stdoutFd, stderrFd], + windowsHide: true + }); + } catch { + closeSync(stdoutFd); + if (stderrFd !== stdoutFd) closeSync(stderrFd); + return 1; + } + + return await new Promise((resolve) => { + let settled = false; + let terminationRequested = false; + const finish = (code: number): void => { + if (settled) return; + settled = true; + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + closeSync(stdoutFd); + if (stderrFd !== stdoutFd) closeSync(stderrFd); + resolve(code); + }; + const forward = (signal: NodeJS.Signals): void => { + terminationRequested = true; + if (!child.killed) child.kill(signal); + }; + const onSigint = (): void => forward("SIGINT"); + const onSigterm = (): void => forward("SIGTERM"); + process.once("SIGINT", onSigint); + process.once("SIGTERM", onSigterm); + child.once("error", () => finish(1)); + child.once("exit", (code) => finish(code ?? (terminationRequested ? 0 : 1))); + }); +} diff --git a/src/service/index.ts b/src/service/index.ts index 85e57eb..40248c4 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -1,5 +1,16 @@ import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + closeSync, + constants, + existsSync, + fstatSync, + ftruncateSync, + lstatSync, + mkdirSync, + openSync, + rmSync, + writeFileSync +} from "node:fs"; import { dirname } from "node:path"; import { resolveStagedWindowsTaskPath } from "../shared/apiary-root.js"; @@ -7,6 +18,7 @@ import { migrateHiveState } from "../shared/state-migration.js"; import { installCommands, legacyUninstallCommands, + startCommands, stopCommands, uninstallCommands, type ServiceCommand @@ -42,6 +54,7 @@ export interface ServiceFs { writeFile(path: string, content: string): void; removeFile(path: string): void; fileExists(path: string): boolean; + isSymbolicLink(path: string): boolean; } export interface ServiceResult { @@ -73,6 +86,7 @@ export interface ServiceUninstallResult extends ServiceResult { export interface ServiceModule { install(): Promise; + start(): Promise; stop(): Promise; uninstall(): Promise; isRegistered(): Promise; @@ -129,13 +143,29 @@ export function createNodeServiceFs(): ServiceFs { mkdirSync(path, { recursive: true }); }, writeFile(path: string, content: string): void { - writeFileSync(path, content, "utf8"); + const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0; + const fd = openSync(path, constants.O_CREAT | constants.O_WRONLY | noFollow, 0o600); + try { + if (!fstatSync(fd).isFile()) throw new Error("refusing non-file service unit"); + ftruncateSync(fd, 0); + writeFileSync(fd, content, "utf8"); + } finally { + closeSync(fd); + } }, removeFile(path: string): void { rmSync(path, { force: true }); }, fileExists(path: string): boolean { return existsSync(path); + }, + isSymbolicLink(path: string): boolean { + try { + return lstatSync(path).isSymbolicLink(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } } }; } @@ -290,19 +320,25 @@ export function createServiceModule(deps: ServiceModuleDeps): ServiceModule { return resolveServicePlan(environment); } - async function isRegisteredForPlan(resolvedPlan: ServicePlan): Promise { + async function isCurrentRegisteredForPlan(resolvedPlan: ServicePlan): Promise { if (resolvedPlan.manager === "schtasks") { const current = await runner.run("schtasks", ["/Query", "/TN", WINDOWS_TASK_NAME], { timeoutMs: SERVICE_COMMAND_TIMEOUT_MS }); - if (current.ok) return true; + return current.ok; + } + return resolvedPlan.unitPath !== "" && fs.fileExists(resolvedPlan.unitPath); + } + + async function isRegisteredForPlan(resolvedPlan: ServicePlan): Promise { + if (await isCurrentRegisteredForPlan(resolvedPlan)) return true; + if (resolvedPlan.manager === "schtasks") { const legacy = await runner.run("schtasks", ["/Query", "/TN", LEGACY_WINDOWS_TASK_NAME], { timeoutMs: SERVICE_COMMAND_TIMEOUT_MS }); return legacy.ok; } - if (resolvedPlan.unitPath !== "" && fs.fileExists(resolvedPlan.unitPath)) return true; const legacyPath = legacyUnitPath(resolvedPlan); return legacyPath !== "" && fs.fileExists(legacyPath); } @@ -345,6 +381,9 @@ export function createServiceModule(deps: ServiceModuleDeps): ServiceModule { if (needsUnitFile) { try { fs.mkdirp(dirname(resolvedPlan.unitPath)); + if (fs.isSymbolicLink(resolvedPlan.unitPath)) { + throw new Error("refusing to replace a symlinked service unit"); + } const windowsUserId = resolvedPlan.manager === "schtasks" ? await resolveWindowsUserId() : null; fs.writeFile(resolvedPlan.unitPath, renderUnit(resolvedPlan, process.env, windowsUserId)); } catch (error) { @@ -373,6 +412,37 @@ export function createServiceModule(deps: ServiceModuleDeps): ServiceModule { }; }, + async start(): Promise { + let resolvedPlan: ServicePlan; + try { + resolvedPlan = withResolvedUnitPath(plan()); + if (!(await isCurrentRegisteredForPlan(resolvedPlan))) { + return { + ok: false, + message: "hive service is not installed; run 'hive service-install' first." + }; + } + } catch (error) { + return { + ok: false, + message: `Could not start hive service: ${error instanceof Error ? error.message : "unknown error"}.` + }; + } + + const { allOk, firstFailure, firstFailureResult } = await runAll( + runner, + startCommands(resolvedPlan, uid), + { isNonFatalFailure: (_command, result) => isAlreadyRunningTaskFailure(result) } + ); + if (!allOk) { + return { + ok: false, + message: `A service-manager start command (${firstFailure?.command ?? "unknown"}) reported an error: ${describeFailure(firstFailureResult)}.` + }; + } + return { ok: true, message: `hive service started (${scopePhrase(resolvedPlan)}).` }; + }, + async stop(): Promise { let resolvedPlan: ServicePlan; try { diff --git a/src/service/templates.ts b/src/service/templates.ts index f448c28..e93d1db 100644 --- a/src/service/templates.ts +++ b/src/service/templates.ts @@ -1,9 +1,10 @@ import { join } from "node:path"; import { WINDOWS_TASK_NAME, type ServicePlan } from "./platform.js"; -import { resolveFleetRoot, resolveLaunchdLogPaths } from "../shared/apiary-root.js"; +import { resolveFleetRoot } from "../shared/apiary-root.js"; -export const HIVE_START_COMMAND = "start" as const; +/** Internal service wrapper. Canonical `start` controls the installed service. */ +export const HIVE_START_COMMAND = "service-daemon" as const; export const RESTART_SEC = 5 as const; export const WINDOWS_RESTART_INTERVAL = "PT1M" as const; /** @@ -42,9 +43,6 @@ export function renderLaunchdPlist(plan: ServicePlan, env: NodeJS.ProcessEnv = p const node = escapeXml(process.execPath); const exec = escapeXml(plan.execPath); const label = escapeXml(plan.label); - const logs = resolveLaunchdLogPaths({ home: plan.home, env }); - const stdoutPath = escapeXml(logs.out); - const stderrPath = escapeXml(logs.err); const pin = apiaryHomePin(plan, env); const environmentBlock = pin === null @@ -75,10 +73,6 @@ ${environmentBlock} Label ${RESTART_SEC} ProcessType Background - StandardOutPath - ${stdoutPath} - StandardErrorPath - ${stderrPath} `; diff --git a/src/shared/apiary-root.ts b/src/shared/apiary-root.ts index 24e1b88..3f1d1de 100644 --- a/src/shared/apiary-root.ts +++ b/src/shared/apiary-root.ts @@ -76,14 +76,19 @@ export function resolveStagedWindowsTaskPath(deps: FleetRootDeps = {}): string { return join(resolveHiveStateDir(deps), "hive-task.xml"); } -export function resolveLaunchdLogPaths(deps: FleetRootDeps = {}): { readonly out: string; readonly err: string } { +/** Authoritative Hive service logs on every platform. */ +export function resolveServiceLogPaths(deps: FleetRootDeps = {}): { readonly out: string; readonly err: string } { const stateDir = resolveHiveStateDir(deps); + const path = join(stateDir, "service.log"); return { - out: join(stateDir, "launchd.out.log"), - err: join(stateDir, "launchd.err.log") + out: path, + err: path }; } +/** @deprecated Use resolveServiceLogPaths; retained for source compatibility. */ +export const resolveLaunchdLogPaths = resolveServiceLogPaths; + /** Absolute pidPath written into hive's registry entry (ADR Resolved decision 4). */ export function resolveHiveRegistryPidPath(deps: FleetRootDeps = {}): string { return resolveHivePidPath(deps); diff --git a/src/terminal-safety.ts b/src/terminal-safety.ts new file mode 100644 index 0000000..376a6f3 --- /dev/null +++ b/src/terminal-safety.ts @@ -0,0 +1,6 @@ +const TERMINAL_ESCAPE_OR_CONTROL = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\)?)|[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu; + +/** Strip control sequences from untrusted values before writing human terminal output. */ +export function sanitizeTerminalText(value: unknown): string { + return String(value).replace(TERMINAL_ESCAPE_OR_CONTROL, ""); +} diff --git a/tests/cli-commands.test.ts b/tests/cli-commands.test.ts index a346bc5..bd3a498 100644 --- a/tests/cli-commands.test.ts +++ b/tests/cli-commands.test.ts @@ -3,8 +3,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + runDaemonCommand, + runInstallCommand, runInstallServiceCommand, runRegisterCommand, + runRestartCommand, runStartCommand, runStopCommand, runUninstallCommand, @@ -44,6 +47,7 @@ function resolveServiceResult(value: T | (() => Promise) | undefined, fall function createFakeService( overrides: { install?: ServiceResult | (() => Promise); + start?: ServiceResult | (() => Promise); stop?: ServiceResult | (() => Promise); uninstall?: ServiceUninstallResult | (() => Promise); isRegistered?: () => Promise; @@ -51,6 +55,7 @@ function createFakeService( ): ServiceModule { return { install: () => resolveServiceResult(overrides.install, { ok: true, message: "installed" }), + start: () => resolveServiceResult(overrides.start, { ok: true, message: "started" }), stop: () => resolveServiceResult(overrides.stop, { ok: true, message: "stopped" }), uninstall: () => resolveServiceResult(overrides.uninstall, { ok: true, alreadyAbsent: false, message: "uninstalled" }), @@ -82,8 +87,8 @@ function telemetryDeps(dir: string, recorder: FetchRecorder, overrides: Partial< const silentOut = (): void => {}; -describe("install-service firing point", () => { - it("fires hive_installed after a successful install, once per machine", () => { +describe("install and service-install firing points", () => { + it("fires hive_installed after a successful full install, once per machine", () => { return withTempDir(async (dir) => { const recorder = createFetchRecorder(); const deps = { @@ -93,12 +98,12 @@ describe("install-service firing point", () => { out: silentOut }; - const code = await runInstallServiceCommand("/tmp/cli.js", deps); + const code = await runInstallCommand("/tmp/cli.js", deps); expect(code).toBe(0); expect(recorder.calls.map((call) => call.body["event"])).toEqual(["hive_installed"]); // Re-install on the same machine: the ledger dedupes, no second event. - const again = await runInstallServiceCommand("/tmp/cli.js", deps); + const again = await runInstallCommand("/tmp/cli.js", deps); expect(again).toBe(0); expect(recorder.calls).toHaveLength(1); }); @@ -107,7 +112,7 @@ describe("install-service firing point", () => { it("does not fire on a failed install and keeps the failure exit code", () => { return withTempDir(async (dir) => { const recorder = createFetchRecorder(); - const code = await runInstallServiceCommand("/tmp/cli.js", { + const code = await runInstallCommand("/tmp/cli.js", { service: createFakeService({ install: { ok: false, message: "boom" } }), registry: { registryPath: join(dir, "doctor.daemons.json") }, telemetry: telemetryDeps(dir, recorder), @@ -121,7 +126,7 @@ describe("install-service firing point", () => { it("a telemetry failure does not change the install exit code", () => { return withTempDir(async (dir) => { const throwing = createFetchRecorder(() => Promise.reject(new Error("network down"))); - const code = await runInstallServiceCommand("/tmp/cli.js", { + const code = await runInstallCommand("/tmp/cli.js", { service: createFakeService(), registry: { registryPath: join(dir, "doctor.daemons.json") }, telemetry: telemetryDeps(dir, throwing), @@ -130,10 +135,24 @@ describe("install-service firing point", () => { expect(code).toBe(0); }); }); + + it("service-install only reconciles the OS service and emits no lifecycle telemetry", () => { + return withTempDir(async (dir) => { + const recorder = createFetchRecorder(); + const code = await runInstallServiceCommand("/tmp/cli.js", { + service: createFakeService(), + telemetry: telemetryDeps(dir, recorder), + out: silentOut + }); + expect(code).toBe(0); + expect(recorder.calls).toEqual([]); + }); + }); + }); -describe("uninstall-service firing point", () => { - it("fires hive_uninstalled (undeduped) and keeps the verb exit code", () => { +describe("service-uninstall firing point", () => { + it("removes only the OS service and emits no lifecycle telemetry", () => { return withTempDir(async (dir) => { const recorder = createFetchRecorder(); const deps = { @@ -143,15 +162,15 @@ describe("uninstall-service firing point", () => { }; const code = await runUninstallServiceCommand("/tmp/cli.js", deps); expect(code).toBe(0); - expect(recorder.calls.map((call) => call.body["event"])).toEqual(["hive_uninstalled"]); + expect(recorder.calls).toEqual([]); // A reinstall/uninstall cycle fires it again (no dedupe on uninstall). await runUninstallServiceCommand("/tmp/cli.js", deps); - expect(recorder.calls).toHaveLength(2); + expect(recorder.calls).toHaveLength(0); }); }); - it("fires even when teardown reports a failure, and the failure code is preserved", () => { + it("preserves a teardown failure without emitting telemetry", () => { return withTempDir(async (dir) => { const recorder = createFetchRecorder(); const code = await runUninstallServiceCommand("/tmp/cli.js", { @@ -162,7 +181,7 @@ describe("uninstall-service firing point", () => { out: silentOut }); expect(code).toBe(1); - expect(recorder.calls.map((call) => call.body["event"])).toEqual(["hive_uninstalled"]); + expect(recorder.calls).toEqual([]); }); }); @@ -181,7 +200,7 @@ describe("uninstall-service firing point", () => { out: silentOut }); expect(code).toBe(0); - expect(recorder.calls.map((call) => call.body["event"])).toEqual(["hive_uninstalled"]); + expect(recorder.calls).toEqual([]); }); }); @@ -198,7 +217,7 @@ describe("uninstall-service firing point", () => { }); }); -describe("start firing points (first_run + updated)", () => { +describe("daemon firing points (first_run + updated)", () => { interface FakeServer { close(callback?: (error?: Error) => void): void; } @@ -231,7 +250,7 @@ describe("start firing points (first_run + updated)", () => { it("fires hive_first_run on the first successful start, once per machine", () => { return withTempDir(async (dir) => { const recorder = createFetchRecorder(); - const code = await runStartCommand(startDeps(dir, recorder)); + const code = await runDaemonCommand(startDeps(dir, recorder)); expect(code).toBe(0); expect(recorder.calls.map((call) => call.body["event"])).toEqual(["hive_first_run"]); expect(loadLedger(join(dir, "state")).lastSeenVersion).toBe("1.0.0"); @@ -241,18 +260,18 @@ describe("start firing points (first_run + updated)", () => { it("fires hive_updated when the persisted version differs from the current one", () => { return withTempDir(async (dir) => { const recorder = createFetchRecorder(); - const first = await runStartCommand(startDeps(dir, recorder, { version: "1.0.0" })); + const first = await runDaemonCommand(startDeps(dir, recorder, { version: "1.0.0" })); expect(first).toBe(0); rmSync(join(dir, "locks"), { recursive: true, force: true }); - const upgraded = await runStartCommand(startDeps(dir, recorder, { version: "1.1.0" })); + const upgraded = await runDaemonCommand(startDeps(dir, recorder, { version: "1.1.0" })); expect(upgraded).toBe(0); expect(recorder.calls.map((call) => call.body["event"])).toEqual(["hive_first_run", "hive_updated"]); expect(loadLedger(join(dir, "state")).lastSeenVersion).toBe("1.1.0"); // Same version again: nothing further fires. rmSync(join(dir, "locks"), { recursive: true, force: true }); - await runStartCommand(startDeps(dir, recorder, { version: "1.1.0" })); + await runDaemonCommand(startDeps(dir, recorder, { version: "1.1.0" })); expect(recorder.calls).toHaveLength(2); }); }); @@ -260,7 +279,7 @@ describe("start firing points (first_run + updated)", () => { it("a telemetry failure does not change the start exit code", () => { return withTempDir(async (dir) => { const throwing = createFetchRecorder(() => Promise.reject(new Error("no network"))); - const code = await runStartCommand(startDeps(dir, throwing)); + const code = await runDaemonCommand(startDeps(dir, throwing)); expect(code).toBe(0); }); }); @@ -297,13 +316,16 @@ describe("stop verb", () => { it("b-AC-1 sends SIGTERM to a live pid when no service is registered", async () => { const lines: string[] = []; const killed: Array<{ pid: number; signal: NodeJS.Signals }> = []; + let alive = true; const code = await runStopCommand("/tmp/cli.js", { service: createFakeService({ isRegistered: async () => false }), pidPath: "/tmp/hive.pid", readPid: () => 4242, - isPidAlive: () => true, + isPidAlive: () => alive, + isOwnedHiveProcess: async () => true, kill: (pid, signal) => { killed.push({ pid, signal }); + alive = false; }, out: (text) => { lines.push(text); @@ -314,6 +336,72 @@ describe("stop verb", () => { expect(lines.join("")).toContain("SIGTERM"); }); + it("converges an orphaned daemon when the service manager reports a successful stop", async () => { + const lines: string[] = []; + const killed: Array<{ pid: number; signal: NodeJS.Signals }> = []; + let alive = true; + const code = await runStopCommand("/tmp/cli.js", { + service: createFakeService({ + isRegistered: async () => true, + stop: { ok: true, message: "hive service stopped (schtasks)." } + }), + pidPath: "/tmp/hive.pid", + readPid: () => 4242, + isPidAlive: () => alive, + isOwnedHiveProcess: async () => true, + kill: (pid, signal) => { + killed.push({ pid, signal }); + alive = false; + }, + out: (text) => lines.push(text) + }); + + expect(code).toBe(0); + expect(killed).toEqual([{ pid: 4242, signal: "SIGTERM" }]); + expect(lines.join("")).toContain("left the hive daemon running"); + }); + + it("rechecks daemon identity after the service-manager stop before signaling", async () => { + const kill = vi.fn(); + const identity = vi.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const code = await runStopCommand("/tmp/cli.js", { + service: createFakeService({ isRegistered: async () => true }), + readPid: () => 4242, + isPidAlive: () => true, + isOwnedHiveProcess: identity, + kill, + out: silentOut + }); + + expect(code).toBe(1); + expect(identity).toHaveBeenCalledTimes(2); + expect(kill).not.toHaveBeenCalled(); + }); + + it("fails within the configured bound when an orphan ignores SIGTERM", async () => { + const kill = vi.fn(); + const delay = vi.fn(async () => {}); + const lines: string[] = []; + const code = await runStopCommand("/tmp/cli.js", { + service: createFakeService({ isRegistered: async () => false }), + readPid: () => 4242, + isPidAlive: () => true, + isOwnedHiveProcess: async () => true, + kill, + stopAttempts: 3, + stopDelayMs: 1, + delay, + out: (text) => lines.push(text) + }); + + expect(code).toBe(1); + expect(kill).toHaveBeenCalledOnce(); + expect(delay).toHaveBeenCalledTimes(2); + expect(lines.join("")).toContain("remained running after SIGTERM"); + }); + it("b-AC-1 stops through the service manager when registered", async () => { const lines: string[] = []; const code = await runStopCommand("/tmp/cli.js", { @@ -329,6 +417,23 @@ describe("stop verb", () => { expect(lines.join("")).toContain("stopped"); }); + it("refuses to signal a live pid that does not identify the expected hive daemon", async () => { + const kill = vi.fn(); + const lines: string[] = []; + const code = await runStopCommand("/tmp/cli.js", { + service: createFakeService({ isRegistered: async () => false }), + readPid: () => 4242, + isPidAlive: () => true, + isOwnedHiveProcess: async () => false, + kill, + out: (text) => lines.push(text) + }); + + expect(code).toBe(1); + expect(kill).not.toHaveBeenCalled(); + expect(lines.join("")).toContain("does not identify the expected hive daemon"); + }); + it("AC-9 a genuine stop failure with the daemon still running exits 1 with the error", async () => { const lines: string[] = []; const code = await runStopCommand("/tmp/cli.js", { @@ -352,6 +457,105 @@ describe("stop verb", () => { }); }); +describe("start and restart service verbs", () => { + it("start delegates to the installed service instead of launching a foreground daemon", async () => { + const lines: string[] = []; + const code = await runStartCommand("/tmp/cli.js", { + service: createFakeService({ start: { ok: true, message: "hive service started (systemd)." } }), + out: (text) => lines.push(text) + }); + expect(code).toBe(0); + expect(lines.join("")).toContain("service started"); + }); + + it("restart stops, starts, and succeeds only after bounded health verification", async () => { + const healthFetch = vi.fn() + .mockRejectedValueOnce(new Error("not ready")) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const delay = vi.fn(async () => {}); + const code = await runRestartCommand("/tmp/cli.js", { + service: createFakeService({ isRegistered: async () => true }), + healthFetch, + healthAttempts: 3, + healthDelayMs: 1, + delay, + out: silentOut + }); + expect(code).toBe(0); + expect(healthFetch).toHaveBeenCalledTimes(2); + expect(delay).toHaveBeenCalledTimes(1); + }); + + it("restart terminates a service-manager orphan before starting the replacement", async () => { + const events: string[] = []; + let alive = true; + const service = createFakeService({ + isRegistered: async () => true, + stop: async () => { + events.push("service-stop"); + return { ok: true, message: "stopped" }; + }, + start: async () => { + events.push("service-start"); + return { ok: true, message: "started" }; + } + }); + const code = await runRestartCommand("/tmp/cli.js", { + service, + pidPath: "/tmp/hive.pid", + readPid: () => 4242, + isPidAlive: () => alive, + isOwnedHiveProcess: async () => true, + kill: () => { + events.push("sigterm"); + alive = false; + }, + healthFetch: async () => ({ ok: true, status: 200 }), + out: silentOut + }); + + expect(code).toBe(0); + expect(events).toEqual(["service-stop", "sigterm", "service-start"]); + }); + + it("restart never starts a replacement while the prior owned pid remains alive", async () => { + const start = vi.fn(async () => ({ ok: true, message: "started" })); + const code = await runRestartCommand("/tmp/cli.js", { + service: createFakeService({ isRegistered: async () => true, start }), + readPid: () => 4242, + isPidAlive: () => true, + isOwnedHiveProcess: async () => true, + kill: vi.fn(), + stopAttempts: 2, + stopDelayMs: 0, + delay: async () => {}, + healthFetch: async () => ({ ok: true, status: 200 }), + out: silentOut + }); + + expect(code).toBe(1); + expect(start).not.toHaveBeenCalled(); + }); + + it("restart fails after the exact configured number of health attempts", async () => { + const healthFetch = vi.fn(async () => ({ ok: false, status: 503 })); + const delay = vi.fn(async () => {}); + const lines: string[] = []; + const code = await runRestartCommand("/tmp/cli.js", { + service: createFakeService({ isRegistered: async () => true }), + healthFetch, + healthAttempts: 3, + healthDelayMs: 1, + delay, + out: (text) => lines.push(text) + }); + expect(code).toBe(1); + expect(healthFetch).toHaveBeenCalledTimes(3); + expect(delay).toHaveBeenCalledTimes(2); + expect(lines.join("")).toContain("did not become healthy"); + }); +}); + describe("uninstall verb", () => { it("b-AC-6 exits 0 when hive is not installed", () => { return withTempDir(async (dir) => { diff --git a/tests/cli-interface.test.ts b/tests/cli-interface.test.ts new file mode 100644 index 0000000..288cced --- /dev/null +++ b/tests/cli-interface.test.ts @@ -0,0 +1,217 @@ +import { validateManifest } from "@legioncodeinc/cli-kit"; + +import { HIVE_MANIFEST, renderHiveHelp, runHiveCli } from "../src/cli-interface.js"; +import { HIVE_VERSION } from "../src/shared/constants.js"; + +function capture(): { readonly lines: string[]; readonly write: (text: string) => void } { + const lines: string[] = []; + return { lines, write: (text) => lines.push(text) }; +} + +describe("Hive CLI contract", () => { + it("publishes the complete canonical command manifest", () => { + expect(HIVE_MANIFEST.commands.map(({ name }) => name)).toEqual([ + "start", + "stop", + "restart", + "status", + "logs", + "install", + "uninstall", + "service-install", + "service-uninstall", + "update", + "register", + "telemetry", + "daemon" + ]); + expect(validateManifest(HIVE_MANIFEST)).toEqual([]); + }); + + it("renders product-specific ASCII branding, uppercase name, credit, groups, and global flags", () => { + const help = renderHiveHelp(80); + expect(help).toContain(" /\\_/\\"); + expect(help).toContain("\nHIVE\n"); + expect(help).toContain("Legion Code Inc. x Activeloop"); + expect(help).toContain("Usage: hive [options]"); + expect(help).toContain("Service lifecycle"); + expect(help).toContain("Installation"); + expect(help).toContain("Fleet"); + expect(help).toContain("Diagnostics"); + expect(help).toContain("Product commands"); + expect(help).toContain("--help, -h"); + expect(help).toContain("--version"); + expect(help).toContain("--json"); + expect(help).toContain("--no-color"); + expect(help.endsWith("\n")).toBe(true); + }); + + it("keeps 80-column, narrow, and color-disabled help text stable and ANSI-free", () => { + const wide = renderHiveHelp(80); + const narrow = renderHiveHelp(40); + expect(wide).toContain(`v${HIVE_VERSION}\nLegion Code Inc. x Activeloop\n`); + expect(narrow).toContain("Apiary colony service coordinator"); + expect(narrow.split("\n").every((line) => line.length <= 40)).toBe(true); + expect(wide).not.toMatch(/\u001b\[/); + expect(narrow).not.toMatch(/\u001b\[/); + }); + + it.each([[[]], [["--help"]], [["-h"]]])("returns help for %j", async (argv: string[]) => { + const stdout = capture(); + expect(await runHiveCli(argv, "/tmp/hive.js", { stdout: stdout.write, stderr: vi.fn() })).toBe(0); + expect(stdout.lines.join("")).toContain("HIVE"); + }); + + it("emits single-source text and JSON versions", async () => { + const textOut = capture(); + expect(await runHiveCli(["--version"], "/tmp/hive.js", { stdout: textOut.write })).toBe(0); + expect(textOut.lines.join("")).toBe(`hive v${HIVE_VERSION}\n`); + + const jsonOut = capture(); + expect(await runHiveCli(["--version", "--json"], "/tmp/hive.js", { stdout: jsonOut.write })).toBe(0); + expect(JSON.parse(jsonOut.lines.join(""))).toMatchObject({ + product: "hive", + command: "version", + ok: true, + version: HIVE_VERSION + }); + }); + + it("emits machine-readable help with every command", async () => { + const stdout = capture(); + expect(await runHiveCli(["--help", "--json"], "/tmp/hive.js", { stdout: stdout.write })).toBe(0); + const body = JSON.parse(stdout.lines.join("")) as { details: { commands: Array<{ name: string }> } }; + expect(body.details.commands.map(({ name }) => name)).toEqual(HIVE_MANIFEST.commands.map(({ name }) => name)); + }); + + it.each([ + ["install-service", "service-install"], + ["uninstall-service", "service-uninstall"] + ])("accepts deprecated alias %s, warns, and dispatches canonical %s", async (alias, canonical) => { + const stderr = capture(); + const execute = vi.fn(async () => 0); + expect(await runHiveCli([alias], "/tmp/hive.js", { stderr: stderr.write, execute })).toBe(0); + expect(stderr.lines.join("")).toContain(`'${alias}' is deprecated`); + expect(execute).toHaveBeenCalledWith(canonical, [], "/tmp/hive.js", expect.any(Function)); + }); + + it("returns usage exit 2 for unknown commands and unexpected positionals", async () => { + const stderr = capture(); + expect(await runHiveCli(["bogus"], "/tmp/hive.js", { stderr: stderr.write })).toBe(2); + expect(stderr.lines.join("")).toContain("unknown command: bogus"); + + stderr.lines.length = 0; + expect(await runHiveCli(["start", "extra"], "/tmp/hive.js", { stderr: stderr.write })).toBe(2); + expect(stderr.lines.join("")).toContain("does not accept positional arguments"); + }); + + it("returns structured JSON for success, command failure, usage failure, and thrown runtime errors", async () => { + const cases = [ + { argv: ["start", "--json"], execute: async () => 0, code: 0, ok: true }, + { argv: ["start", "--json"], execute: async () => 1, code: 1, ok: false }, + { argv: ["bogus", "--json"], execute: async () => 0, code: 2, ok: false }, + { argv: ["start", "--json"], execute: async () => { throw new Error("service exploded"); }, code: 1, ok: false } + ] as const; + + for (const scenario of cases) { + const stdout = capture(); + const code = await runHiveCli(scenario.argv, "/tmp/hive.js", { + stdout: stdout.write, + stderr: vi.fn(), + execute: scenario.execute + }); + expect(code).toBe(scenario.code); + expect(JSON.parse(stdout.lines.join(""))).toMatchObject({ product: "hive", ok: scenario.ok }); + } + }); + + it("routes human command success to stdout and operational failure to stderr", async () => { + const successOut = capture(); + const successErr = capture(); + expect(await runHiveCli(["start"], "/tmp/hive.js", { + stdout: successOut.write, + stderr: successErr.write, + execute: async (_command, _args, _path, out) => { out("started\n"); return 0; } + })).toBe(0); + expect(successOut.lines.join("")).toBe("started\n"); + expect(successErr.lines.join("")).toBe(""); + + const failureOut = capture(); + const failureErr = capture(); + expect(await runHiveCli(["start"], "/tmp/hive.js", { + stdout: failureOut.write, + stderr: failureErr.write, + execute: async (_command, _args, _path, out) => { out("service failed\n"); return 1; } + })).toBe(1); + expect(failureOut.lines.join("")).toBe(""); + expect(failureErr.lines.join("")).toBe("service failed\n"); + }); + + it("strips terminal controls from untrusted human command output", async () => { + const stdout = capture(); + const stderr = capture(); + expect(await runHiveCli(["start"], "/tmp/hive.js", { + stdout: stdout.write, + stderr: stderr.write, + execute: async (_command, _args, _path, out) => { out("safe\u001b]8;;https://evil.example\u0007click\u001b]8;;\u0007\n"); return 1; } + })).toBe(1); + expect(stdout.lines.join("")).toBe(""); + expect(stderr.lines.join("")).toBe("safeclick\n"); + }); + + it("requires explicit uninstall confirmation and never executes after a decline", async () => { + const stdout = capture(); + const execute = vi.fn(async () => 0); + const confirmRemoval = vi.fn(async () => false); + expect(await runHiveCli(["uninstall"], "/tmp/hive.js", { + stdout: stdout.write, + execute, + confirmRemoval + })).toBe(0); + expect(confirmRemoval).toHaveBeenCalledWith(false); + expect(execute).not.toHaveBeenCalled(); + expect(stdout.lines.join("")).toContain("cancelled"); + }); + + it("requires --yes for JSON uninstall and strips it before dispatch", async () => { + const denied = capture(); + expect(await runHiveCli(["uninstall", "--json"], "/tmp/hive.js", { + stdout: denied.write, + execute: vi.fn() + })).toBe(2); + expect(JSON.parse(denied.lines.join(""))).toMatchObject({ ok: false, command: "uninstall" }); + + const accepted = capture(); + const execute = vi.fn(async () => 0); + const confirmRemoval = vi.fn(async () => true); + expect(await runHiveCli(["uninstall", "--yes", "--json"], "/tmp/hive.js", { + stdout: accepted.write, + execute, + confirmRemoval + })).toBe(0); + expect(confirmRemoval).toHaveBeenCalledWith(true); + expect(execute).toHaveBeenCalledWith("uninstall", [], "/tmp/hive.js", expect.any(Function)); + expect(JSON.parse(accepted.lines.join(""))).toMatchObject({ ok: true, command: "uninstall" }); + }); + + it("keeps status runtime failures inside the single JSON envelope", async () => { + const stdout = capture(); + const stderr = capture(); + const service = { + install: async () => ({ ok: true, message: "installed" }), + start: async () => ({ ok: true, message: "started" }), + stop: async () => ({ ok: true, message: "stopped" }), + uninstall: async () => ({ ok: true, alreadyAbsent: false, message: "removed" }), + isRegistered: async () => { throw new Error("status exploded"); } + }; + expect(await runHiveCli(["status", "--json"], "/tmp/hive.js", { + stdout: stdout.write, + stderr: stderr.write, + status: { service, readPid: () => null } + })).toBe(1); + expect(stderr.lines.join("")).toBe(""); + expect(JSON.parse(stdout.lines.join(""))).toMatchObject({ + product: "hive", command: "status", ok: false, message: "status exploded" + }); + }); +}); diff --git a/tests/cli-observability.test.ts b/tests/cli-observability.test.ts new file mode 100644 index 0000000..c83a080 --- /dev/null +++ b/tests/cli-observability.test.ts @@ -0,0 +1,221 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { LogFileSystem } from "@legioncodeinc/cli-kit"; + +import { runHiveCli } from "../src/cli-interface.js"; +import { + inspectHiveStatus, + inspectHiveTelemetry, + runLogsCommand +} from "../src/cli-observability.js"; +import { registerHiveWithDoctor } from "../src/install/registry.js"; +import type { ServiceModule } from "../src/service/index.js"; +import { resolveHiveStateDir, resolveServiceLogPaths } from "../src/shared/apiary-root.js"; + +function service(registered: boolean): ServiceModule { + return { + install: async () => ({ ok: true, message: "installed" }), + start: async () => ({ ok: true, message: "started" }), + stop: async () => ({ ok: true, message: "stopped" }), + uninstall: async () => ({ ok: true, alreadyAbsent: false, message: "uninstalled" }), + isRegistered: async () => registered + }; +} + +function withTempDir(run: (dir: string) => Promise | void): Promise { + const dir = mkdtempSync(join(tmpdir(), "hive-observability-")); + return Promise.resolve(run(dir)).finally(() => rmSync(dir, { recursive: true, force: true })); +} + +describe("status", () => { + it("reports installation, live PID, bounded health, Doctor registration, and Hive-owned paths", () => { + return withTempDir(async (home) => { + const fleetRoot = { home, env: {}, platform: "linux" as const }; + const registryPath = join(home, ".apiary", "registry.json"); + registerHiveWithDoctor({ registryPath }); + const healthFetch = vi.fn(async () => ({ ok: true, status: 200 })); + const status = await inspectHiveStatus("/tmp/hive.js", { + service: service(true), + fleetRoot, + registry: { registryPath }, + readPid: () => 4242, + pidAlive: () => true, + healthFetch + }); + + expect(status).toMatchObject({ + product: "hive", + installation: "installed", + process: { state: "running", pid: 4242 }, + health: { state: "healthy", result: "HTTP 200" }, + registration: "registered", + paths: { + config: resolveHiveStateDir(fleetRoot), + logs: resolveServiceLogPaths(fleetRoot).out + } + }); + expect(healthFetch).toHaveBeenCalledTimes(1); + }); + }); + + it("does not probe health for a stopped process and emits JSON through the CLI", async () => { + const stdout: string[] = []; + const healthFetch = vi.fn(); + const code = await runHiveCli(["status", "--json"], "/tmp/hive.js", { + stdout: (text) => stdout.push(text), + status: { + service: service(false), + readPid: () => null, + healthFetch, + registry: { registryPath: "/definitely/absent/registry.json" } + } + }); + expect(code).toBe(0); + const body = JSON.parse(stdout.join("")) as { details: { status: Record } }; + expect(body.details.status).toMatchObject({ + installation: "not-installed", + process: { state: "stopped" }, + health: { state: "not-applicable" }, + registration: "unregistered" + }); + expect(healthFetch).not.toHaveBeenCalled(); + }); + + it("renders an unhealthy running service in both human and JSON modes", async () => { + for (const json of [false, true]) { + const stdout: string[] = []; + const code = await runHiveCli(["status", ...(json ? ["--json"] : [])], "/tmp/hive.js", { + stdout: (text) => stdout.push(text), + status: { + service: service(true), + readPid: () => 99, + pidAlive: () => true, + healthFetch: async () => ({ ok: false, status: 503 }), + registry: { registryPath: "/definitely/absent/registry.json" } + } + }); + expect(code).toBe(0); + if (json) expect(JSON.parse(stdout.join("")).details.status.health.state).toBe("unhealthy"); + else expect(stdout.join("")).toContain("Health: unhealthy"); + } + }); +}); + +describe("telemetry summary", () => { + it("reports the controlling opt-out setting, disabled destination, and last successful send", () => { + return withTempDir((home) => { + const fleetRoot = { home, env: {}, platform: "linux" as const }; + const stateDir = resolveHiveStateDir(fleetRoot); + mkdirSync(stateDir, { recursive: true }); + writeFileSync(join(stateDir, "telemetry.json"), JSON.stringify({ + reported: { + "event:first": "2026-01-01T00:00:00.000Z", + "event:last": "2026-02-01T00:00:00.000Z" + } + })); + + expect(inspectHiveTelemetry({ + fleetRoot, + env: { HONEYCOMB_TELEMETRY: "0" }, + posthogKey: "phc_present" + })).toMatchObject({ + state: "opted-out", + controllingSetting: "HONEYCOMB_TELEMETRY=0", + destination: "disabled", + lastSuccessfulSend: "2026-02-01T00:00:00.000Z" + }); + }); + }); + + it("emits a structured, read-only JSON summary", async () => { + const stdout: string[] = []; + expect(await runHiveCli(["telemetry", "--json"], "/tmp/hive.js", { + stdout: (text) => stdout.push(text), + telemetry: { env: { DO_NOT_TRACK: "1" }, posthogKey: "phc_present" } + })).toBe(0); + expect(JSON.parse(stdout.join(""))).toMatchObject({ + product: "hive", + command: "telemetry", + ok: true, + details: { telemetry: { state: "opted-out", controllingSetting: "DO_NOT_TRACK" } } + }); + }); + + it("renders telemetry-enabled human output without exposing the destination key", async () => { + const stdout: string[] = []; + expect(await runHiveCli(["telemetry"], "/tmp/hive.js", { + stdout: (text) => stdout.push(text), + telemetry: { env: {}, posthogKey: "phc_secret_must_not_render" } + })).toBe(0); + expect(stdout.join("")).toContain("Telemetry: enabled"); + expect(stdout.join("")).toContain("Destination: hosted"); + expect(stdout.join("")).not.toContain("phc_secret_must_not_render"); + }); +}); + +describe("logs", () => { + it("reads only the hard-bound Hive service log, honors line limits, and redacts secrets", () => { + return withTempDir(async (home) => { + const fleetRoot = { home, env: {}, platform: "linux" as const }; + const expectedRoot = resolveHiveStateDir(fleetRoot); + const expectedPath = resolveServiceLogPaths(fleetRoot).out; + const readFile = vi.fn(async (path: string) => { + expect(path).toBe(expectedPath); + return "honeycomb should never be read\nhive old\nhive access_token=abc123\n"; + }); + const fs: LogFileSystem = { + readFile, + realpath: async (path) => path, + watch: () => ({ close: () => {} }) + }; + const output: string[] = []; + expect(await runLogsCommand(["--lines", "1", "--no-follow"], { + fleetRoot, + fs, + out: (text) => output.push(text) + })).toBe(0); + expect(readFile).toHaveBeenCalledTimes(1); + expect(expectedPath.startsWith(expectedRoot)).toBe(true); + expect(output.join("")).toBe("hive access_token=[REDACTED]\n"); + }); + }); + + it("rejects unknown log options with usage exit 2 before reading any file", async () => { + const readFile = vi.fn(async () => ""); + const output: string[] = []; + const code = await runLogsCommand(["--product", "honeycomb"], { + fs: { readFile, realpath: async (path) => path, watch: () => ({ close: () => {} }) }, + out: (text) => output.push(text) + }); + expect(code).toBe(2); + expect(readFile).not.toHaveBeenCalled(); + expect(output.join("")).toContain("unknown logs option"); + }); + + it("emits missing-log failures on stderr in human mode and one JSON envelope in JSON mode", async () => { + const fs: LogFileSystem = { + readFile: async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }, + realpath: async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }, + watch: () => ({ close: () => {} }) + }; + const humanOut: string[] = []; + const humanErr: string[] = []; + expect(await runHiveCli(["logs", "--no-follow"], "/tmp/hive.js", { + stdout: (text) => humanOut.push(text), + stderr: (text) => humanErr.push(text), + logs: { fs } + })).toBe(1); + expect(humanOut).toEqual([]); + expect(humanErr.join("")).toContain("log"); + + const jsonOut: string[] = []; + expect(await runHiveCli(["logs", "--no-follow", "--json"], "/tmp/hive.js", { + stdout: (text) => jsonOut.push(text), + stderr: vi.fn(), + logs: { fs } + })).toBe(1); + expect(JSON.parse(jsonOut.join(""))).toMatchObject({ product: "hive", command: "logs", ok: false }); + }); +}); diff --git a/tests/cli-update.test.ts b/tests/cli-update.test.ts new file mode 100644 index 0000000..9417760 --- /dev/null +++ b/tests/cli-update.test.ts @@ -0,0 +1,152 @@ +import type { ServiceModule } from "../src/service/index.js"; +import { + runUpdateCommand, + updateCommandInternals, + type UpdateExec, + type UpdateExecResult +} from "../src/cli-update.js"; + +interface RecordedExec { + readonly executable: string; + readonly args: readonly string[]; +} + +function execSequence(results: readonly UpdateExecResult[]): { readonly calls: RecordedExec[]; readonly exec: UpdateExec } { + const calls: RecordedExec[] = []; + let index = 0; + return { + calls, + exec: async (executable, args) => { + calls.push({ executable, args: [...args] }); + const result = results[index++]; + if (result === undefined) throw new Error("unexpected updater invocation"); + return result; + } + }; +} + +function ok(stdout = ""): UpdateExecResult { + return { ok: true, stdout, stderr: "" }; +} + +function failed(stderr = "failed"): UpdateExecResult { + return { ok: false, stdout: "", stderr }; +} + +function recordingService(registered = true) { + const calls: string[] = []; + const service: ServiceModule = { + install: async () => ({ ok: true, message: "installed" }), + start: async () => { calls.push("start"); return { ok: true, message: "started" }; }, + stop: async () => { calls.push("stop"); return { ok: true, message: "stopped" }; }, + uninstall: async () => ({ ok: true, alreadyAbsent: false, message: "uninstalled" }), + isRegistered: async () => { calls.push("isRegistered"); return registered; } + }; + return { calls, service }; +} + +describe("Hive approved-channel updater", () => { + it("uses fixed argv for lookup and exact-version global install without a shell", async () => { + const runner = execSequence([ok('"1.2.0"'), ok()]); + const service = recordingService(false); + const lines: string[] = []; + expect(await runUpdateCommand("/tmp/hive.js", { + installedVersion: "1.1.0", + platform: "linux", + exec: runner.exec, + service: service.service, + out: (text) => lines.push(text) + })).toBe(0); + expect(runner.calls).toEqual([ + { executable: "npm", args: ["view", "@legioncodeinc/hive", "version", "--json"] }, + { executable: "npm", args: ["install", "--global", "@legioncodeinc/hive@1.2.0"] } + ]); + expect(service.calls).toEqual(["isRegistered"]); + expect(lines.join("")).toContain("state was preserved"); + }); + + it("is an idempotent no-op when the approved version is installed", async () => { + const runner = execSequence([ok("1.1.0\n")]); + const service = recordingService(); + expect(await runUpdateCommand("/tmp/hive.js", { + installedVersion: "1.1.0", + exec: runner.exec, + service: service.service, + out: vi.fn() + })).toBe(0); + expect(runner.calls).toHaveLength(1); + expect(service.calls).toEqual([]); + }); + + it("rejects an unapproved version-shaped injection before executing install", async () => { + const runner = execSequence([ok('"1.2.0 && whoami"')]); + expect(await runUpdateCommand("/tmp/hive.js", { + installedVersion: "1.1.0", + exec: runner.exec, + out: vi.fn() + })).toBe(1); + expect(runner.calls).toHaveLength(1); + }); + + it("restarts the prior installed service when the package update itself fails", async () => { + const runner = execSequence([ok('"1.2.0"'), failed()]); + const service = recordingService(); + expect(await runUpdateCommand("/tmp/hive.js", { + installedVersion: "1.1.0", + exec: runner.exec, + service: service.service, + out: vi.fn() + })).toBe(1); + expect(service.calls).toEqual(["isRegistered", "stop", "start"]); + expect(runner.calls[1]?.args).toEqual(["install", "--global", "@legioncodeinc/hive@1.2.0"]); + }); + + it("rolls back with fixed argv when post-update health fails, then verifies recovery", async () => { + const runner = execSequence([ok('"1.2.0"'), ok(), ok()]); + const service = recordingService(); + const healthFetch = vi.fn() + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: true, status: 200 }); + const lines: string[] = []; + expect(await runUpdateCommand("/tmp/hive.js", { + installedVersion: "1.1.0", + platform: "win32", + exec: runner.exec, + service: service.service, + healthFetch, + healthAttempts: 1, + out: (text) => lines.push(text) + })).toBe(1); + expect(runner.calls).toEqual([ + { executable: "npm.cmd", args: ["view", "@legioncodeinc/hive", "version", "--json"] }, + { executable: "npm.cmd", args: ["install", "--global", "@legioncodeinc/hive@1.2.0"] }, + { executable: "npm.cmd", args: ["install", "--global", "@legioncodeinc/hive@1.1.0"] } + ]); + expect(service.calls).toEqual(["isRegistered", "stop", "start", "stop", "start"]); + expect(healthFetch).toHaveBeenCalledTimes(2); + expect(lines.join("")).toContain("Rollback to 1.1.0 completed"); + }); + + it("reports manual repair when rollback cannot be installed", async () => { + const runner = execSequence([ok('"1.2.0"'), ok(), failed("registry unavailable")]); + const service = recordingService(); + const lines: string[] = []; + expect(await runUpdateCommand("/tmp/hive.js", { + installedVersion: "1.1.0", + exec: runner.exec, + service: service.service, + healthFetch: async () => ({ ok: false, status: 503 }), + healthAttempts: 1, + out: (text) => lines.push(text) + })).toBe(1); + expect(service.calls).toEqual(["isRegistered", "stop", "start", "stop"]); + expect(lines.join("")).toContain("manual repair is required"); + }); + + it("accepts semver releases and rejects shell tokens in parsed npm output", () => { + expect(updateCommandInternals.parseApprovedVersion('"2.0.0-beta.1"')).toBe("2.0.0-beta.1"); + expect(updateCommandInternals.parseApprovedVersion("2.0.0;rm -rf /")).toBeNull(); + expect(updateCommandInternals.npmExecutable("win32")).toBe("npm.cmd"); + expect(updateCommandInternals.npmExecutable("darwin")).toBe("npm"); + }); +}); diff --git a/tests/daemon/installer/funnel-telemetry.test.ts b/tests/daemon/installer/funnel-telemetry.test.ts index d544900..b25a5e5 100644 --- a/tests/daemon/installer/funnel-telemetry.test.ts +++ b/tests/daemon/installer/funnel-telemetry.test.ts @@ -69,6 +69,9 @@ function telemetryHarness(extra: Parameters[0] = {}) { const harness = makeHarness({ ...extra, overrides: { + // Keep unrelated funnel tests hermetic: the default harness may observe a live/local + // authenticated setup state and asynchronously inject login_completed into their recorder. + setupAuthFetch: unauthenticatedSetupFetch, funnelEmitDeps: deps, funnelStateDir: join(dir, "state"), ...extra.overrides diff --git a/tests/dashboard/use-swr.test.tsx b/tests/dashboard/use-swr.test.tsx index b66a05f..2b972c8 100644 --- a/tests/dashboard/use-swr.test.tsx +++ b/tests/dashboard/use-swr.test.tsx @@ -98,7 +98,9 @@ describe("useSwr", () => { }); it("revalidateOnFocus: fires a revalidate on visibilitychange→visible; skips while hidden", async () => { - setVisibility("visible"); + // Start hidden so an interval deadline cannot race the transition under test. + // Initial SWR hydration is unconditional, so the mount fetch still runs. + setVisibility("hidden"); let calls = 0; const { result } = renderHook(() => @@ -112,7 +114,6 @@ describe("useSwr", () => { await waitFor(() => expect(result.current.data).toBe("r1")); // Background the tab — interval ticks should NOT fire a fetch. - setVisibility("hidden"); await act(async () => { await new Promise((r) => setTimeout(r, 120)); }); diff --git a/tests/install/registry.test.ts b/tests/install/registry.test.ts index 274c7db..83d6ed3 100644 --- a/tests/install/registry.test.ts +++ b/tests/install/registry.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -6,6 +6,7 @@ import { buildHiveRegistryEntry, createNodeRegistryFs, deleteHiveFromDoctor, + RegistryDocumentError, registerHiveWithDoctor, registryContainsHiveEntry, resolveRegistryWritePath, @@ -69,6 +70,7 @@ describe("hive registry writer", () => { const hive = parsed.daemons.find((entry) => entry["name"] === "hive"); expect(hive).toEqual(buildHiveRegistryEntry()); expect(hive?.["pidPath"]).toBe(resolveHiveRegistryPidPath()); + if (process.platform !== "win32") expect(statSync(registryPath).mode & 0o777).toBe(0o600); }); }); @@ -165,6 +167,78 @@ describe("hive registry writer", () => { }); }); + it("fails closed on malformed registry JSON without discarding peer data", () => { + return withTempDir((dir) => { + const registryPath = join(dir, "registry.json"); + const malformed = '{"daemons":[{"name":"honeycomb"}],'; + writeFileSync(registryPath, malformed, "utf8"); + + expect(() => registerHiveWithDoctor({ registryPath })).toThrow(RegistryDocumentError); + expect(() => deleteHiveFromDoctor({ registryPath })).toThrow(RegistryDocumentError); + expect(readFileSync(registryPath, "utf8")).toBe(malformed); + expect(readdirSync(dir).some((name) => name.includes(".tmp-") || name.endsWith(".lock"))).toBe(false); + }); + }); + + it("performs the complete read-modify-rename transaction while holding the registry lock", () => { + const registryPath = "/virtual/registry.json"; + const base = createMemoryRegistryFs({ + [registryPath]: JSON.stringify({ daemons: [{ name: "honeycomb" }] }) + }); + let lockDepth = 0; + const fs: RegistryFs = { + ...base, + readFile(path) { + expect(lockDepth).toBe(1); + return base.readFile(path); + }, + rename(from, to) { + expect(lockDepth).toBe(1); + base.rename(from, to); + }, + withLock(_path, operation) { + expect(lockDepth).toBe(0); + lockDepth += 1; + try { return operation(); } finally { lockDepth -= 1; } + } + }; + + registerHiveWithDoctor({ registryPath, fs }); + expect(lockDepth).toBe(0); + expect(JSON.parse(base.files.get(registryPath) ?? "{}").daemons).toEqual([ + { name: "honeycomb" }, + buildHiveRegistryEntry() + ]); + }); + + it("reclaims an old lock only when its recorded owner is dead", () => { + return withTempDir((dir) => { + const registryPath = join(dir, "registry.json"); + const lockPath = `${registryPath}.lock`; + writeFileSync(lockPath, JSON.stringify({ pid: 2_147_483_647, token: "dead", createdAt: 0 })); + const old = new Date(Date.now() - 60_000); + utimesSync(lockPath, old, old); + + expect(registerHiveWithDoctor({ registryPath }).updatedExistingEntry).toBe(false); + expect(readdirSync(dir).some((name) => name.endsWith(".lock"))).toBe(false); + expect(JSON.parse(readFileSync(registryPath, "utf8")).daemons).toContainEqual(buildHiveRegistryEntry()); + }); + }); + + it("does not reclaim an old lock whose recorded owner is still alive", () => { + return withTempDir((dir) => { + const registryPath = join(dir, "registry.json"); + const lockPath = `${registryPath}.lock`; + const content = JSON.stringify({ pid: process.pid, token: "live", createdAt: 0 }); + writeFileSync(lockPath, content); + const old = new Date(Date.now() - 60_000); + utimesSync(lockPath, old, old); + + expect(() => registerHiveWithDoctor({ registryPath })).toThrow("Timed out waiting for Doctor registry lock"); + expect(readFileSync(lockPath, "utf8")).toBe(content); + }); + }, 5_000); + it("b-AC-3 deleteHiveFromDoctor removes hive and preserves other entries", () => { return withTempDir((dir) => { const registryPath = join(dir, "registry.json"); diff --git a/tests/process-identity.test.ts b/tests/process-identity.test.ts new file mode 100644 index 0000000..5a68195 --- /dev/null +++ b/tests/process-identity.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { argvIdentifiesHive, commandLineIdentifiesHive } from "../src/process-identity.js"; + +describe("hive process identity", () => { + it("accepts the exact CLI entry followed by the daemon verb", () => { + expect(argvIdentifiesHive(["node", "/opt/hive/dist/cli.js", "daemon"], "/opt/hive/dist/cli.js")).toBe(true); + }); + + it("rejects a different executable and a non-daemon Hive command", () => { + expect(argvIdentifiesHive(["node", "/opt/other/dist/cli.js", "daemon"], "/opt/hive/dist/cli.js")).toBe(false); + expect(argvIdentifiesHive(["node", "/opt/hive/dist/cli.js", "status"], "/opt/hive/dist/cli.js")).toBe(false); + }); + + it("recognizes the Windows scheduled-task daemon command line", () => { + const path = "C:\\Users\\mario\\AppData\\Roaming\\npm\\node_modules\\@legioncodeinc\\hive\\dist\\cli.js"; + expect(commandLineIdentifiesHive(`"C:\\Program Files\\nodejs\\node.exe" ${path} daemon`, path)).toBe(true); + }); + + it("rejects command lines where the expected path and daemon token are not adjacent", () => { + const path = "C:\\Users\\mario\\hive\\dist\\cli.js"; + expect(commandLineIdentifiesHive(`node ${path} status --note daemon`, path)).toBe(false); + expect(commandLineIdentifiesHive(`node other.js ${path} daemon`, path)).toBe(true); + }); +}); diff --git a/tests/service/commands.test.ts b/tests/service/commands.test.ts index b52772c..32c9f01 100644 --- a/tests/service/commands.test.ts +++ b/tests/service/commands.test.ts @@ -1,6 +1,7 @@ import { launchdDomainTarget, launchdServiceTarget, + startCommands, stopCommands, uninstallCommands } from "../../src/service/commands.js"; @@ -36,3 +37,21 @@ describe("service stop commands", () => { expect(uninstallCommands(plan, 0)[0]?.args).toEqual(["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"]); }); }); + +describe("service start commands", () => { + it("uses fixed product-owned argv on all platforms", () => { + const launchd = resolveServicePlan(fixedEnv({ platform: "darwin", home: "/home/t" })); + const systemd = resolveServicePlan(fixedEnv({ platform: "linux", home: "/home/t" })); + const windows = resolveServicePlan(fixedEnv({ platform: "win32", home: "C:\\Users\\t" })); + + expect(startCommands(launchd, 501)).toEqual([ + { command: "launchctl", args: ["kickstart", launchdServiceTarget(launchd, 501)] } + ]); + expect(startCommands(systemd, 1000)).toEqual([ + { command: "systemctl", args: ["--user", "start", SYSTEMD_UNIT_NAME] } + ]); + expect(startCommands(windows, 0)).toEqual([ + { command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] } + ]); + }); +}); diff --git a/tests/service/daemon-wrapper.test.ts b/tests/service/daemon-wrapper.test.ts new file mode 100644 index 0000000..1fb7676 --- /dev/null +++ b/tests/service/daemon-wrapper.test.ts @@ -0,0 +1,67 @@ +import { EventEmitter } from "node:events"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ChildProcess, spawn } from "node:child_process"; + +import { runServiceDaemon } from "../../src/service/daemon-wrapper.js"; +import { resolveServiceLogPaths } from "../../src/shared/apiary-root.js"; + +describe("service daemon wrapper", () => { + it("spawns the foreground daemon with fixed argv, no shell, and Hive-owned logs", async () => { + const home = mkdtempSync(join(tmpdir(), "hive-service-wrapper-")); + try { + const child = new EventEmitter() as ChildProcess; + Object.defineProperty(child, "killed", { value: false, writable: true }); + child.kill = vi.fn(() => true); + const spawnChild = vi.fn(() => { + queueMicrotask(() => child.emit("exit", 0, null)); + return child; + }) as unknown as typeof spawn; + const fleetRoot = { home, env: {}, platform: "linux" as const }; + + expect(await runServiceDaemon("/opt/hive/dist/cli.js", { fleetRoot, spawnChild })).toBe(0); + expect(spawnChild).toHaveBeenCalledWith( + process.execPath, + ["/opt/hive/dist/cli.js", "daemon"], + expect.objectContaining({ shell: false, windowsHide: true }) + ); + expect(existsSync(resolveServiceLogPaths(fleetRoot).out)).toBe(true); + if (process.platform !== "win32") expect(statSync(resolveServiceLogPaths(fleetRoot).out).mode & 0o777).toBe(0o600); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("repairs permissive permissions on an existing service log", async () => { + const home = mkdtempSync(join(tmpdir(), "hive-service-wrapper-mode-")); + try { + const fleetRoot = { home, env: {}, platform: "linux" as const }; + const log = resolveServiceLogPaths(fleetRoot).out; + mkdirSync(join(home, ".apiary", "hive"), { recursive: true }); + writeFileSync(log, "existing"); + chmodSync(log, 0o666); + const child = new EventEmitter() as ChildProcess; + Object.defineProperty(child, "killed", { value: false, writable: true }); + child.kill = vi.fn(() => true); + const spawnChild = vi.fn(() => { queueMicrotask(() => child.emit("exit", 0, null)); return child; }) as unknown as typeof spawn; + expect(await runServiceDaemon("/opt/hive/dist/cli.js", { fleetRoot, spawnChild })).toBe(0); + if (process.platform !== "win32") expect(statSync(log).mode & 0o777).toBe(0o600); + } finally { rmSync(home, { recursive: true, force: true }); } + }); + + it("refuses a symlinked service log without spawning the daemon", async () => { + const home = mkdtempSync(join(tmpdir(), "hive-service-wrapper-link-")); + try { + const fleetRoot = { home, env: {}, platform: "linux" as const }; + const log = resolveServiceLogPaths(fleetRoot).out; + const target = join(home, "victim.txt"); + mkdirSync(join(home, ".apiary", "hive"), { recursive: true }); + writeFileSync(target, "untouched"); + symlinkSync(target, log, "file"); + const spawnChild = vi.fn() as unknown as typeof spawn; + expect(await runServiceDaemon("/opt/hive/dist/cli.js", { fleetRoot, spawnChild })).toBe(1); + expect(spawnChild).not.toHaveBeenCalled(); + } finally { rmSync(home, { recursive: true, force: true }); } + }); +}); diff --git a/tests/service/helpers.ts b/tests/service/helpers.ts index a6dfaaa..3aad284 100644 --- a/tests/service/helpers.ts +++ b/tests/service/helpers.ts @@ -28,17 +28,20 @@ export interface MemoryFs extends ServiceFs { readonly files: Map; readonly mkdirs: string[]; readonly removed: string[]; + readonly symlinks: Set; } export function createMemoryFs(failWrite = false): MemoryFs { const files = new Map(); const mkdirs: string[] = []; const removed: string[] = []; + const symlinks = new Set(); return { files, mkdirs, removed, + symlinks, mkdirp(path: string): void { mkdirs.push(path); }, @@ -52,6 +55,9 @@ export function createMemoryFs(failWrite = false): MemoryFs { }, fileExists(path: string): boolean { return files.has(path); + }, + isSymbolicLink(path: string): boolean { + return symlinks.has(path); } }; } diff --git a/tests/service/service-module.test.ts b/tests/service/service-module.test.ts index 5382bf0..f291a98 100644 --- a/tests/service/service-module.test.ts +++ b/tests/service/service-module.test.ts @@ -3,6 +3,56 @@ import { resolveStagedWindowsTaskPath } from "../../src/shared/apiary-root.js"; import { fixedEnv, createMemoryFs, createRecordingRunner } from "./helpers.js"; describe("hive service module", () => { + it("reconciles an existing Unix unit so CLI migrations replace stale actions", async () => { + const runner = createRecordingRunner(); + const fs = createMemoryFs(); + const unitPath = "/home/t/.config/systemd/user/hive.service"; + fs.files.set(unitPath, "ExecStart=node /opt/hive/dist/cli.js start\n"); + let migrated = 0; + const service = createServiceModule({ + execPath: "/opt/hive/dist/cli.js", + runner, + fs, + environment: fixedEnv({ platform: "linux", home: "/home/t", execPath: "/opt/hive/dist/cli.js" }), + migrateState: () => { + migrated += 1; + } + }); + + const result = await service.install(); + + expect(result.ok).toBe(true); + expect(migrated).toBe(1); + expect(fs.files.get(unitPath)).toContain(`"/opt/hive/dist/cli.js" service-daemon`); + expect(runner.calls).toContainEqual({ + command: "systemctl", + args: ["--user", "enable", "--now", "hive.service"] + }); + }); + + it("refuses to rewrite a symlinked service unit", async () => { + const runner = createRecordingRunner(); + const fs = createMemoryFs(); + const unitPath = "/home/t/.config/systemd/user/hive.service"; + fs.symlinks.add(unitPath); + const service = createServiceModule({ + execPath: "/opt/hive/dist/cli.js", + runner, + fs, + environment: fixedEnv({ platform: "linux", home: "/home/t", execPath: "/opt/hive/dist/cli.js" }), + migrateState: () => {} + }); + + const result = await service.install(); + + expect(result.ok).toBe(false); + expect(result.message).toContain("refusing to replace a symlinked service unit"); + expect(runner.calls).not.toContainEqual({ + command: "systemctl", + args: ["--user", "enable", "--now", "hive.service"] + }); + }); + it("d-AC-1 writes Linux unit content before systemctl enable", async () => { const runner = createRecordingRunner(); const fs = createMemoryFs(); @@ -26,7 +76,7 @@ describe("hive service module", () => { expect(migrated).toBe(1); expect(fs.files.has(unitPath)).toBe(true); expect(fs.files.get(unitPath)).toContain("Restart=always"); - expect(fs.files.get(unitPath)).toContain(`"/opt/hive/dist/cli.js" start`); + expect(fs.files.get(unitPath)).toContain(`"/opt/hive/dist/cli.js" service-daemon`); // Decision #32 migration: the legacy `thehive` unit is deregistered (and its // file removed) first, then the new unit is enabled. expect(runner.calls[0]).toEqual({ diff --git a/tests/service/templates.test.ts b/tests/service/templates.test.ts index be834d9..2b38d89 100644 --- a/tests/service/templates.test.ts +++ b/tests/service/templates.test.ts @@ -1,5 +1,4 @@ import { resolveServicePlan } from "../../src/service/platform.js"; -import { resolveLaunchdLogPaths } from "../../src/shared/apiary-root.js"; import { apiaryHomePin, quoteSystemdToken, @@ -15,6 +14,17 @@ import { import { fixedEnv } from "./helpers.js"; describe("hive service templates", () => { + it("AC-b9 pins every platform to the fixed service-daemon logging boundary", () => { + const launchd = renderLaunchdPlist(resolveServicePlan(fixedEnv({ platform: "darwin", execPath: "/opt/hive/dist/cli.js" }))); + const systemd = renderSystemdUnit(resolveServicePlan(fixedEnv({ platform: "linux", execPath: "/opt/hive/dist/cli.js" }))); + const windows = renderScheduledTaskXml(resolveServicePlan(fixedEnv({ platform: "win32", execPath: "C:\\hive\\dist\\cli.js" }))); + + expect(launchd).toContain(`${HIVE_START_COMMAND}`); + expect(systemd).toContain(` ${HIVE_START_COMMAND}\n`); + expect(windows).toContain(` ${HIVE_START_COMMAND}`); + expect(`${launchd}\n${systemd}`).not.toMatch(/StandardOut(?:Path|put)|StandardErr(?:orPath|or)/); + }); + it("rr-AC-10 pins APIARY_HOME into the launchd EnvironmentVariables dict when an override root is active", () => { const plan = resolveServicePlan(fixedEnv({ platform: "darwin", home: "/Users/t", execPath: "/opt/hive/dist/cli.js" })); const xml = renderLaunchdPlist(plan, { APIARY_HOME: "/custom/fleet-root" }); @@ -22,9 +32,8 @@ describe("hive service templates", () => { expect(xml).toContain("EnvironmentVariables"); expect(xml).toContain("APIARY_HOME"); expect(xml).toContain("/custom/fleet-root"); - // The unit's log paths and the pinned root agree (the W-1 coherence requirement). - const logs = resolveLaunchdLogPaths({ home: "/Users/t", env: { APIARY_HOME: "/custom/fleet-root" } }); - expect(xml).toContain(`${logs.out}`); + // The service wrapper receives the same pinned root and owns symlink-safe log opening. + expect(xml).toContain(`${HIVE_START_COMMAND}`); }); it("rr-AC-10 XML-escapes a metacharacter-bearing pinned root in the launchd plist", () => { @@ -51,13 +60,13 @@ describe("hive service templates", () => { expect(apiaryHomePin(darwinPlan, {})).toBeNull(); }); - it("rr-AC-9 renders launchd log paths under the fleet hive state dir", () => { + it("rr-AC-9 delegates log ownership to the cross-platform service wrapper", () => { const plan = resolveServicePlan(fixedEnv({ platform: "darwin", home: "/Users/t", execPath: "/opt/hive/dist/cli.js" })); const xml = renderLaunchdPlist(plan); - const logs = resolveLaunchdLogPaths({ home: "/Users/t", env: process.env }); - expect(xml).toContain(`${logs.out}`); - expect(xml).toContain(`${logs.err}`); + expect(xml).toContain(`${HIVE_START_COMMAND}`); + expect(xml).not.toContain("StandardOutPath"); + expect(xml).not.toContain("StandardErrorPath"); }); it("d-AC-2/d-AC-3 renders launchd boot+restart settings", () => { @@ -79,6 +88,8 @@ describe("hive service templates", () => { expect(unit).toContain("WantedBy=default.target"); expect(unit).toContain(quoteSystemdToken(process.execPath)); expect(unit).toContain(`${quoteSystemdToken("/opt/hive/dist/cli.js")} ${HIVE_START_COMMAND}`); + expect(unit).not.toContain("StandardOutput="); + expect(unit).not.toContain("StandardError="); }); it("d-AC-2/d-AC-3 renders schtasks restart-on-failure + logon trigger", () => { diff --git a/tests/shared/apiary-root.test.ts b/tests/shared/apiary-root.test.ts index 3bff0ae..a966f33 100644 --- a/tests/shared/apiary-root.test.ts +++ b/tests/shared/apiary-root.test.ts @@ -62,7 +62,7 @@ describe("resolveFleetRoot (rr-AC-1..4)", () => { expect(resolveHiveLockPath(deps)).toBe(join(root, "hive", "hive.lock")); expect(resolveSharedInstallIdPath(deps)).toBe(join(root, "install-id")); expect(resolveStagedWindowsTaskPath(deps)).toBe(join(root, "hive", "hive-task.xml")); - expect(resolveLaunchdLogPaths(deps).out).toBe(join(root, "hive", "launchd.out.log")); + expect(resolveLaunchdLogPaths(deps).out).toBe(join(root, "hive", "service.log")); }); it("rr-AC-1 production default uses os.homedir()", () => { diff --git a/vitest.config.ts b/vitest.config.ts index ef50880..6934e51 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ environment: "node", include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"], globals: true, + testTimeout: 10_000, setupFiles: ["tests/setup/isolate-home.ts"] } }); From b63d5308ad597ac52ace7baed7783761a73480a2 Mon Sep 17 00:00:00 2001 From: hive-release-bot Date: Mon, 13 Jul 2026 09:04:24 +0000 Subject: [PATCH 2/5] chore(release): minor bump [approved] --- .changeset/standardize-hive-cli.md | 5 ----- CHANGELOG.md | 4 ++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) delete mode 100644 .changeset/standardize-hive-cli.md diff --git a/.changeset/standardize-hive-cli.md b/.changeset/standardize-hive-cli.md deleted file mode 100644 index 08599b1..0000000 --- a/.changeset/standardize-hive-cli.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@legioncodeinc/hive": minor ---- - -Standardize Hive on the Apiary CLI contract with branded help, canonical lifecycle verbs, JSON output, service logs, status, telemetry, and safe update support. `start` now controls the installed service; use `hive daemon` for foreground execution. The old `install-service` and `uninstall-service` spellings remain as deprecated aliases during the migration window. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9a935..1c71f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.12.0 — 2026-07-13 + +Standardize Hive on the Apiary CLI contract with branded help, canonical lifecycle verbs, JSON output, service logs, status, telemetry, and safe update support. `start` now controls the installed service; use `hive daemon` for foreground execution. The old `install-service` and `uninstall-service` spellings remain as deprecated aliases during the migration window. + ## Unreleased — PRD-003 CLI interface adoption Hive now follows the shared Apiary CLI contract. The canonical operational surface is `start`, `stop`, `restart`, `install`, `uninstall`, `service-install`, `service-uninstall`, `update`, `status`, `register`, `logs`, and `telemetry`, with `--help`, `--version`, `--json`, and `--no-color`; `daemon` remains Hive's product-specific foreground command. diff --git a/package-lock.json b/package-lock.json index b0fa32d..ec205a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@legioncodeinc/hive", - "version": "0.11.1", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@legioncodeinc/hive", - "version": "0.11.1", + "version": "0.12.0", "license": "AGPL-3.0-or-later", "dependencies": { "@hono/node-server": "^2.0.6", diff --git a/package.json b/package.json index 749e6ad..cf178e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@legioncodeinc/hive", - "version": "0.11.1", + "version": "0.12.0", "description": "Hive portal daemon", "license": "AGPL-3.0-or-later", "type": "module", From 0f865b135f9a8c078dfd12bd5f09d742137e55e6 Mon Sep 17 00:00:00 2001 From: release-bot Date: Mon, 13 Jul 2026 05:22:17 -0400 Subject: [PATCH 3/5] fix(registry): serialize absent-entry deletion --- CHANGELOG.md | 4 ---- src/install/registry.ts | 6 ------ tests/install/registry.test.ts | 20 ++++++++++++++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c71f9f..0862b3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,6 @@ ## v0.12.0 — 2026-07-13 -Standardize Hive on the Apiary CLI contract with branded help, canonical lifecycle verbs, JSON output, service logs, status, telemetry, and safe update support. `start` now controls the installed service; use `hive daemon` for foreground execution. The old `install-service` and `uninstall-service` spellings remain as deprecated aliases during the migration window. - -## Unreleased — PRD-003 CLI interface adoption - Hive now follows the shared Apiary CLI contract. The canonical operational surface is `start`, `stop`, `restart`, `install`, `uninstall`, `service-install`, `service-uninstall`, `update`, `status`, `register`, `logs`, and `telemetry`, with `--help`, `--version`, `--json`, and `--no-color`; `daemon` remains Hive's product-specific foreground command. This is an operator-visible semantic migration: diff --git a/src/install/registry.ts b/src/install/registry.ts index bfd0d40..489b5bf 100644 --- a/src/install/registry.ts +++ b/src/install/registry.ts @@ -252,12 +252,6 @@ function registryHasHiveEntry(path: string, fs: RegistryFs): boolean { } function deleteHiveEntryAtPath(path: string, fs: RegistryFs): boolean { - try { - fs.readFile(path); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; - throw error; - } fs.mkdirp(dirname(path)); return withRegistryLock(path, fs, () => { const parsed = readRegistryDocument(path, fs); diff --git a/tests/install/registry.test.ts b/tests/install/registry.test.ts index 83d6ed3..3f13267 100644 --- a/tests/install/registry.test.ts +++ b/tests/install/registry.test.ts @@ -273,6 +273,26 @@ describe("hive registry writer", () => { }); }); + it("acquires the registry lock before deciding an initially absent entry is missing", () => { + const registryPath = "/virtual/registry.json"; + const base = createMemoryRegistryFs({}); + let lockAcquired = false; + const fs: RegistryFs = { + ...base, + withLock(_path, operation) { + lockAcquired = true; + base.files.set(registryPath, JSON.stringify({ daemons: [buildHiveRegistryEntry()] })); + return operation(); + } + }; + + const result = deleteHiveFromDoctor({ registryPath, fs }); + + expect(lockAcquired).toBe(true); + expect(result).toEqual({ removed: true, registryPaths: [registryPath] }); + expect(JSON.parse(base.files.get(registryPath) ?? "{}").daemons).toEqual([]); + }); + it("b-AC-3 default delete fans out over the write, fleet, and legacy registry paths", () => { // No registryPath override: exercises the real candidate chain // [resolveRegistryWritePath(), resolveFleetRegistryPath(), resolveLegacyDoctorRegistryPath()] From 2c74ee1b1a19710bb88e08c03ce8b9cf6fdf8fb5 Mon Sep 17 00:00:00 2001 From: release-bot Date: Mon, 13 Jul 2026 05:23:12 -0400 Subject: [PATCH 4/5] docs(qa): pin final Hive audit evidence --- .../2026-07-13-prd-003-hive-cli-adoption-qa.md | 15 ++++++++------- ...13-prd-003-hive-cli-adoption-security-audit.md | 13 +++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md b/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md index 77d7a5a..9d700dc 100644 --- a/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md +++ b/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md @@ -1,14 +1,15 @@ # QA Report: PRD-003 Hive CLI Adoption -**Plan document:** `C:/Users/mario/GitHub/the-apiary/cli-kit/library/requirements/backlog/prd-003-apiary-cli-interface-standard/` +**Plan document:** `library/requirements/backlog/prd-003-apiary-cli-interface-standard/` in the `cli-kit` repository **Audit date:** 2026-07-13 **Base branch:** `main` -**Head:** `legion/prd-003-cli-standard-hive` (dirty, unpushed working tree) +**Audited revision:** `0f865b135f9a8c078dfd12bd5f09d742137e55e6` on `legion/prd-003-cli-standard-hive` +**Evidence classification:** Local feature-branch validation of the immutable audited revision; not published-release evidence **Auditor:** quality-worker-bee ## Summary -The Hive-local PRD-003 adoption is implementation-complete and receives a final clean QA PASS after security's final PASS. Process identity now requires exact CLI-path plus adjacent `daemon` tokens and is revalidated immediately before every SIGTERM after service-manager stop; service-unit writes use no-follow descriptor checks before truncation. The full 834/834-test suite, packed-tarball conformance, and repeated live Windows `0.11.1` PID replacement all pass with no remaining finding. +The Hive-local PRD-003 adoption is implementation-complete and receives a final clean QA PASS after security's final PASS. Process identity now requires exact CLI-path plus adjacent `daemon` tokens and is revalidated immediately before every SIGTERM after service-manager stop; service-unit writes use no-follow descriptor checks before truncation. Registry deletion now performs its absence check under the shared registry lock. The full 835/835-test suite, packed-tarball conformance, and repeated live Windows `0.11.1` PID replacement all pass with no remaining finding. ## Scorecard @@ -87,7 +88,7 @@ None. | AC-e3 | Pass | `scripts/verify-packed-cli.mjs`; alias/unit tests | Installed tarball passes Hive matrix and deprecated aliases remain tested | | AC-e4 | N/A | Nectar | Not a Hive criterion | | AC-e5 | Pass | `CHANGELOG.md`; migration note; rewritten active runbook | Repository documentation is internally consistent | -| AC-e6 | Pending ship evidence | `.github/workflows/ci.yaml:47-99` | Three-OS test/build/packed job configured; no run claimed for unpushed tree | +| AC-e6 | Pending current-revision CI | `.github/workflows/ci.yaml:47-99` | Three-OS test/build/packed job is configured; PR run 29237688148 passed on prior revision `b63d530`, while `0f865b1` has complete local evidence and awaits its pushed CI rerun | | AC-e7 | N/A | suite-level repository | Fleet-wide four-package job is outside Hive-local adoption | | AC-e8 | Pass (Hive) | handler tests, packed conformance, registry/log isolation tests | No silent stubs or wrong-product delegation | | AC-e9 | Pass | shared command matrix plus Hive migration/runbook links | Normative suite semantics are linked consistently | @@ -99,7 +100,7 @@ None. |---|---| | Final security re-review | PASS; no open Critical, High, or Medium finding | | Focused PRD-003 tests | PASS: 11 files, 119 tests | -| Full `npm test` | PASS on rerun: 97 files, 834 tests; first run had one unrelated onboarding timeout and its isolated rerun passed 6/6 | +| Full `npm test` | PASS on audited revision: 97 files, 835 tests | | Test-stability delta | PASS: 10s Vitest ceiling, hidden-first visibility fixture, and hermetic unauthenticated funnel seam affect tests only; no production path changed | | `npm run typecheck` | PASS | | `npm run build` | PASS | @@ -120,7 +121,7 @@ The dogfood evidence matches the corrected lifecycle transaction: - Unit tests prove an orphan is signaled after a successful manager stop, a stuck orphan returns exit `1` after the exact configured bound, and restart never starts a replacement until the prior PID is gone. - Final dogfood security verdict is **PASS**: exact tokenized identity, immediate pre-signal revalidation, and descriptor-based unit-file protection close both prior Medium findings. -Final delta gates: local identity/lifecycle/service focused suite 3 files/54 tests passed; security focused suite 4 files/68 tests passed; full suite rerun 97 files/834 tests passed; typecheck, build, packed conformance, audit, and diff check passed. +Final delta gates: local identity/lifecycle/service focused suite 3 files/54 tests passed; security focused suite 4 files/68 tests passed; registry focused suite 13/13 passed; full suite on audited revision `0f865b1` passed 97 files/835 tests; typecheck, build, packed conformance, audit, and diff check passed. ## Prior Blocker Closure @@ -139,6 +140,6 @@ Final delta gates: local identity/lifecycle/service focused suite 3 files/54 tes - `package.json`, `package-lock.json` (M), add cli-kit and packed-test script. - `scripts/verify-packed-cli.mjs` (A), bounded installed-tarball conformance verification. - CLI, lifecycle, registry, observability, updater, service wrapper/template, terminal, and path sources under `src/` (A/M), implement Hive adoption and security boundaries. -- Corresponding `tests/` files (A/M), include exact-token identity, post-stop revalidation, orphan convergence, bounded failure, and descriptor/symlink cases and contribute to the 834-test green suite. +- Corresponding `tests/` files (A/M), include exact-token identity, post-stop revalidation, orphan convergence, bounded failure, descriptor/symlink cases, and locked registry deletion and contribute to the 835-test green suite. - `vitest.config.ts` (M), raises the per-test timeout to 10 seconds for parallel CI load without changing runtime code. - Security and quality reports under `library/qa/` (A), record final independent review evidence. diff --git a/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md b/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md index a37768f..efa9934 100644 --- a/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md +++ b/library/qa/security/2026-07-13-prd-003-hive-cli-adoption-security-audit.md @@ -2,15 +2,15 @@ **Audit date:** 2026-07-13 **Auditor:** security-worker-bee -**Scope:** All uncommitted PRD-003 adoption files, with focused review of CLI dispatch, lifecycle/uninstall, service adapters/templates/wrapper, updater, registry, status/logs/telemetry, shared paths, tests, package metadata, and migration documentation +**Scope:** Immutable feature-branch revision `0f865b135f9a8c078dfd12bd5f09d742137e55e6`, with focused review of CLI dispatch, lifecycle/uninstall, service adapters/templates/wrapper, updater, registry, status/logs/telemetry, shared paths, tests, package metadata, and migration documentation **Node requirement:** >=22 **`npm audit` result:** 0 vulnerabilities **OpenClaw bundle scan:** Not applicable; Hive ships no OpenClaw bundle/audit script -**CVE catalog:** Security Stinger catalog refreshed 2026-04-25 (current) +**CVE catalog:** Security Stinger catalog refreshed 2026-04-25; coverage is limited to that snapshot and is not represented as current through the 2026-07-13 audit date ## Executive Summary -Scope note: Hive's native service managers, local registry, dashboard daemon, and npm updater are outside the Stinger's Hivemind-specific Deep Lake catalog; universal command execution, filesystem, credential, terminal, network-bound, and supply-chain controls were applied. Three High findings were fixed: service logs could follow a planted symlink, existing logs and registry replacement files could retain permissive modes, and untrusted service/environment text could inject terminal control sequences. Follow-up reviews removed launchd/systemd's direct log opening and closed the prior Medium registry finding with owner-aware locking plus fail-closed malformed-document handling. A quality report was produced before this final security re-review and is stale; quality must rerun after these changes. The PRD-003 security gate is **PASS**. +Scope note: Hive's native service managers, local registry, dashboard daemon, and npm updater are outside the Stinger's Hivemind-specific Deep Lake catalog; universal command execution, filesystem, credential, terminal, network-bound, and supply-chain controls were applied. Three High findings were fixed: service logs could follow a planted symlink, existing logs and registry replacement files could retain permissive modes, and untrusted service/environment text could inject terminal control sequences. Follow-up reviews removed launchd/systemd's direct log opening and closed the prior Medium registry finding with owner-aware locking plus fail-closed malformed-document handling. The post-security quality report and this audit now reference the same immutable revision and 835-test validation run. The PRD-003 security gate is **PASS**. ## Scorecard @@ -70,10 +70,11 @@ None detected. | `npm run build` | PASS | | `npm audit --audit-level=high` | PASS - 0 vulnerabilities | | `git diff --check` | PASS | -| Full `npm test` (final security run) | 821/822 passed; one unrelated onboarding gate test hit its five-second timeout | +| Full `npm test` (commit-consistent final run) | PASS - 97 files, 835 tests on `0f865b1` | +| Focused registry tests | PASS - 13 tests, including locked initially-absent deletion | | `npm run test:packed-cli` | PASS - packed install and CLI conformance, with bounded fixed-argv subprocesses and cleanup | -The single full-suite timeout did not touch the PRD-003 files or security remediations; focused and packed suites are deterministic and green. Quality must rerun after this final security pass because its existing report predates the owner-aware lock changes. +The commit-consistent full suite, focused registry suite, and packed artifact checks are green. The QA report records the same audited revision and test count. ## Files Changed by Security Review @@ -92,7 +93,7 @@ The single full-suite timeout did not touch the PRD-003 files or security remedi ## Security Gate Verdict -**PASS.** No Critical, High, or Medium security finding remains open in the Hive PRD-003 adoption. The final dogfood addendum below records and closes the lifecycle-convergence follow-ups. Quality must include this addendum in its rerun. +**PASS.** No Critical, High, or Medium security finding remains open in the Hive PRD-003 adoption. The final dogfood addendum below records and closes the lifecycle-convergence follow-ups, and the final QA report incorporates this evidence. ## Dogfood Lifecycle Convergence Addendum - 2026-07-13 From 6041e620b688ee3ab1881cdad4f47157a8c28e9b Mon Sep 17 00:00:00 2001 From: release-bot Date: Mon, 13 Jul 2026 05:27:05 -0400 Subject: [PATCH 5/5] ci: move checkout actions to Node 24 runtime --- .github/workflows/ci.yaml | 2 +- .github/workflows/release-gate.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/tag-on-merge.yaml | 2 +- library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bd9b6dd..9123a5c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -58,7 +58,7 @@ jobs: # (zizmor artipacked hardening). This gate only reads the repo; it # never pushes. - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/release-gate.yaml b/.github/workflows/release-gate.yaml index b1c9c12..b29c5a3 100644 --- a/.github/workflows/release-gate.yaml +++ b/.github/workflows/release-gate.yaml @@ -76,7 +76,7 @@ jobs: && !contains(github.event.pull_request.labels.*.name, 'no-changeset') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ace0178..4d0ec4e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6 with: persist-credentials: false diff --git a/.github/workflows/tag-on-merge.yaml b/.github/workflows/tag-on-merge.yaml index 52a8658..19c811c 100644 --- a/.github/workflows/tag-on-merge.yaml +++ b/.github/workflows/tag-on-merge.yaml @@ -28,7 +28,7 @@ jobs: tag: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6 with: fetch-depth: 0 persist-credentials: false diff --git a/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md b/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md index 9d700dc..25e3510 100644 --- a/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md +++ b/library/qa/quality/2026-07-13-prd-003-hive-cli-adoption-qa.md @@ -88,7 +88,7 @@ None. | AC-e3 | Pass | `scripts/verify-packed-cli.mjs`; alias/unit tests | Installed tarball passes Hive matrix and deprecated aliases remain tested | | AC-e4 | N/A | Nectar | Not a Hive criterion | | AC-e5 | Pass | `CHANGELOG.md`; migration note; rewritten active runbook | Repository documentation is internally consistent | -| AC-e6 | Pending current-revision CI | `.github/workflows/ci.yaml:47-99` | Three-OS test/build/packed job is configured; PR run 29237688148 passed on prior revision `b63d530`, while `0f865b1` has complete local evidence and awaits its pushed CI rerun | +| AC-e6 | Pass | `.github/workflows/ci.yaml:47-99`; PR run 29238911214 | Ubuntu, macOS, and Windows passed test, build, packed conformance, and pack sanity on evidence-only descendant `2c74ee1`; audited production source matches `0f865b1` | | AC-e7 | N/A | suite-level repository | Fleet-wide four-package job is outside Hive-local adoption | | AC-e8 | Pass (Hive) | handler tests, packed conformance, registry/log isolation tests | No silent stubs or wrong-product delegation | | AC-e9 | Pass | shared command matrix plus Hive migration/runbook links | Normative suite semantics are linked consistently | @@ -107,7 +107,7 @@ None. | `npm run test:packed-cli` | PASS: packed install and CLI conformance | | `npm audit --audit-level=high` | PASS: 0 vulnerabilities | | `git diff --check` | PASS (line-ending warnings only) | -| Native CI | Configured for Ubuntu/macOS/Windows; pending push/run evidence | +| Native CI | PASS - run 29238911214 completed successfully on Ubuntu, macOS, and Windows | ## Live Dogfood Lifecycle Addendum