From 70daf9a67b5f1bc42f6b12e85918080a876b212a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 15:09:20 -0400 Subject: [PATCH] beehive: real beehive command launcher with default host folder Signed-off-by: Logan Johnson --- beehive/FIRST_DEMO.md | 39 ++++--- beehive/README.md | 21 +++- beehive/bin/beehive.cjs | 43 ++++++++ beehive/src/cli.ts | 27 ++++- beehive/test/launcher.test.ts | 186 ++++++++++++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 18 deletions(-) create mode 100755 beehive/bin/beehive.cjs create mode 100644 beehive/test/launcher.test.ts diff --git a/beehive/FIRST_DEMO.md b/beehive/FIRST_DEMO.md index 9e60f02c8e..79198e0af4 100644 --- a/beehive/FIRST_DEMO.md +++ b/beehive/FIRST_DEMO.md @@ -8,8 +8,20 @@ compatibility. Do not use frozen or existing installations for this demo. ## Prerequisites -- Node 24.15.0 and this package's frozen standalone dependencies. In `beehive`, - invoke `/absolute/path/to/node src/cli.ts ...`; no root bootstrap is needed. +- Node 24.15.0 (any Node.js 22.18+ works; the `beehive` launcher checks the + version and says what to do) and this package's frozen standalone + dependencies. Install the command once, reversibly, into a user bin directory + already on your PATH, matching nothing else: + `ln -s /absolute/path/to/this/checkout/beehive/bin/beehive.cjs ~/.local/bin/beehive` + (adjust the target to a user-owned bin directory that is on *your* PATH; + undo with `rm`). Then run `beehive ` from any directory. Advanced + fallback inside the package: `/absolute/path/to/node src/cli.ts `; + no root bootstrap is needed either way. +- `beehive setup` with no directory uses the default host state folder + `~/.beehive/host`, resolved from your home (never the current directory) and + displayed once before the wizard. An occupied default is never silently + reinitialized; pass an explicit directory for additional hosts. Owner + approval/catalog files below stay explicit and never land in host state. - A deliberately chosen **new** 0700 host directory, a dedicated host service OS user with an interactive unlocked credential store, and a separate owner context. `@napi-rs/keyring` 2.0.0 may show OS approval/unlock prompts; it cannot suppress @@ -31,16 +43,18 @@ compatibility. Do not use frozen or existing installations for this demo. ## Manual setup → Start → reply → Stop -1. **Host, owner present:** `node src/cli.ts setup /new/private-host` - Choose `create`, supply label, owner's **public** hex key and `wss://relay-host`; +1. **Host, owner present:** `beehive setup` + (default host folder `~/.beehive/host`; `beehive setup ` + for an additional host). Choose `create`, supply label, owner's **public** hex key and `wss://relay-host`; choose a new private pairing file. Expect OS key creation/readback approval. If interrupted, use `export` on the same directory; never regenerate identity. 2. Transfer the pairing file privately to the owner. **Owner context:** run - `node src/cli.ts setup /new/owner-exchange`, choose `approve`, select the pairing, + `beehive setup /new/owner-exchange` (an explicit directory keeps owner + approval files outside host state), choose `approve`, select the pairing, compare the full fingerprint with the host, confirm `yes`, enter a future Unix expiration and a new private approval file. Enter the existing owner key only at the hidden prompt. Transfer the approval file privately back to the host. -3. **Host:** rerun `node src/cli.ts setup /new/private-host`, choose `import`, select +3. **Host:** rerun `beehive setup` on the same default host folder, choose `import`, select that approval. This registers infrastructure only. Arrange the **already-member** prerequisite above out of band; there is no administrative operation here. 4. Prepare a private local `binding.json` (absolute paths, no identity keys): @@ -70,22 +84,23 @@ compatibility. Do not use frozen or existing installations for this demo. or invent a new root to bypass its assignment**; use a separately authorized initial demo identity/placement, not migration or Move. - Run `node src/cli.ts provision-agent /new/private-host /private/binding.json /private/genesis.json`. + Run `beehive provision-agent ~/.beehive/host /private/binding.json /private/genesis.json`. Enter the matching agent key at the hidden prompt. Expect OS create/readback prompts. The owner key is never placed on the host. If interrupted, retain all artifacts and use `reconcile-provision` with these exact same inputs, not a new key. -5. Run `node src/cli.ts auth-info /new/private-host` for the exact service-user +5. Run `beehive auth-info ~/.beehive/host` for the exact service-user native login context. Deliberately configure/login the supported provider as that user only. No borrowed Desktop cache. Presence of files is not login proof. -6. **Owner:** `node src/cli.ts catalog /new/private-catalog.json /private/approval.json`. +6. **Owner:** `beehive catalog /new/private-catalog.json /private/approval.json`. 7. **Host, owner present:** - `node src/cli.ts host /new/private-host wss://relay-host --owner-present` - The flag consents to live OS reads, not relay admin or provider login. Each key + `beehive host wss://relay-host --owner-present` + (default host folder; the explicit `beehive host + --owner-present` form remains available). The flag consents to live OS reads, not relay admin or provider login. Each key read uses an owned helper with a 10s deadline; SIGINT/SIGTERM cancels and awaits its actual close. An OS-owned dialog may remain after helper termination; inspect/dismiss it deliberately. Denied/timeout is not a missing key. Expect a fresh host-signed HTTP membership check, then `Host online`. -8. **Owner:** `node src/cli.ts tui /private/private-catalog.json wss://relay-host`. +8. **Owner:** `beehive tui /private/private-catalog.json wss://relay-host`. Enter owner key at the hidden prompt. A separate owner-signed membership check precedes private transport. Use `hosts`, `select `, `show`, then `start`. Wait for `completed | accepted` and running actual state; publication diff --git a/beehive/README.md b/beehive/README.md index 7fc92f4057..4c2ef3ddf8 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -10,9 +10,28 @@ can close while the host/runner continues. No direct-host HTTP API or controller service exists. This package intentionally lives alongside, not inside, the native clients: it is a separately authorized product experiment. +## Command launcher (one-time, reversible) + +`bin/beehive.cjs` is a plain launcher for this checkout: it checks Node +(>= 22.18, with a short actionable error otherwise) and runs `src/cli.ts` from +its own real location, so it works from any directory, through a user +symlink, and with spaces in paths. Install once into a user-owned bin +directory already on your PATH (matching no existing command): + +```sh +ln -s "$(pwd)/bin/beehive.cjs" ~/.local/bin/beehive # adjust if your PATH differs +``` + +`beehive setup` (no directory) then uses the default host state folder +`~/.beehive/host`, resolved from your home and displayed once; an occupied +default is never reinitialized. Pass an explicit directory for additional +hosts. Owner approval/catalog files remain explicit and outside host state. +Undo the command with `rm ~/.local/bin/beehive` (or your equivalent link). + ## Offline standalone host enrollment (partial, no network) -`node src/cli.ts setup ` without an identity file opens Beehive's +`beehive setup ` (or `node src/cli.ts setup ` inside the +package) without an identity file opens Beehive's own enrollment interface. No Buzz Desktop is needed. Use **create** on a fresh host directory: enter its label, relay URL and your existing owner **public** key. The host generates its durable independent key and exports a private pairing file. diff --git a/beehive/bin/beehive.cjs b/beehive/bin/beehive.cjs new file mode 100755 index 0000000000..973797aa84 --- /dev/null +++ b/beehive/bin/beehive.cjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/* Beehive standalone command launcher. + * + * Runs the package CLI (src/cli.ts) from this checkout under the same Node.js, + * resolving everything from this file's real location so it works from any + * directory, through a user-installed symlink, and from paths containing + * spaces. One-time reversible user install (no shell profile or system + * change), for example into a user bin directory already on PATH: + * + * ln -s /absolute/path/to/beehive/bin/beehive.cjs ~/.local/bin/beehive + * + * Plain CommonJS on purpose: the Node version gate must be checkable before + * any TypeScript loading (the CLI needs Node's type stripping). */ +'use strict'; +const { spawnSync } = require('node:child_process'); +const { join } = require('node:path'); + +/** Mirrors package.json "engines": Node.js >= 22.18.0. */ +function meetsMinimumNode(version, minimum) { + const [major, minor] = String(version).split('.').map(Number); + const [minimumMajor, minimumMinor] = String(minimum).split('.').map(Number); + return major > minimumMajor || (major === minimumMajor && minor >= minimumMinor); +} + +function run() { + if (!meetsMinimumNode(process.versions.node, '22.18.0')) { + console.error(`beehive: Node.js 22.18.0 or newer is required; this launcher ran under Node ${process.versions.node}. Install a current Node.js (https://nodejs.org), make sure it is on PATH, and run beehive again.`); + process.exit(1); + } + const cli = join(__dirname, '..', 'src', 'cli.ts'); + const child = spawnSync(process.execPath, [cli, ...process.argv.slice(2)], { stdio: 'inherit' }); + if (child.error) { + console.error(`beehive: cannot start the Beehive CLI at ${cli}: ${child.error.message}`); + process.exit(1); + } + if (child.status !== null) process.exit(child.status); + if (child.signal) process.kill(process.pid, child.signal); // Propagate Ctrl-C/termination as the same signal. + process.exit(1); +} + +if (require.main === module) run(); + +module.exports = { meetsMinimumNode }; diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 7d8b1c1168..a80d6fe45a 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -21,6 +21,7 @@ import { verifyHostCatalog, type HostCatalog } from './host-catalog.ts'; import { Profiles, profile, profileRevision, type Profile } from './profiles.ts'; import { createInterface } from 'node:readline/promises'; import { stdin, stdout } from 'node:process'; +import { homedir } from 'node:os'; import { existsSync, mkdirSync, realpathSync, rmdirSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { relay } from './relay.ts'; @@ -36,10 +37,15 @@ import { prepareConversation } from './conversation.ts'; const operationLabel = (m: Message) => `${m.host} ${m.type}${m.type === 'move' ? ` → ${String(m.body.target)}` : ''} | agent ${m.agent} | operation ${m.id}`; const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`; const [command, ...args] = process.argv.slice(2); +// Default HOST state only, resolved from the current user's home (never cwd). Owner approval/catalog +// files stay explicit and never land in this folder. An occupied default is never reinitialized. +const defaultHostDirectory = () => join(homedir(), '.beehive', 'host'); const help = `Beehive — private host preview (explicit direct relay membership required) +Command: beehive from any directory once installed (one-time user link, reversible: ln -s /bin/beehive.cjs /beehive, e.g. ~/.local/bin); source fallback inside the package: node src/cli.ts . identity Disabled: use your existing owner signer - setup Offline host pairing/owner approval/import - setup LEGACY loopback diagnostic setup (owner key copied) + setup Offline pairing/owner approval/import on the default host folder ~/.beehive/host + setup Explicit host folder (additional hosts/tests); same offline wizard + setup LEGACY loopback diagnostic setup (owner key copied) provision-agent Hidden matching agent import; OS credentials reconcile-provision Explicit exact first-provision recovery catalog Retain verified public host registrations @@ -55,9 +61,11 @@ const help = `Beehive — private host preview (explicit direct relay membership auth-info [binding-id] Print local harness service context (no login) conversation-setup Legacy guidance; use local-setup action normal relay Dedicated ciphertext relay - host --owner-present Private foreground host; bounded OS key reads + host --owner-present Private foreground host on the default host folder; bounded OS key reads + host --owner-present Private foreground host on an explicit folder; bounded OS key reads tui Private owner UI; hidden existing owner signer setup/add-agent accept optional for standby import. +The default host folder resolves from your home (~/.beehive/host), never the current directory; setup displays it once and never reinitializes an existing installation. Owner approval/catalog files remain explicit and separate from host state. reconcile-agent derives the interrupted binding and genesis from the retained journal; an optional public genesis file must match it. assignment-export accepts agent public key after filename when several slots exist. Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC. Private host/catalog requires fresh membership-enforced relay verification; no automatic private reconnect.`; @@ -79,6 +87,11 @@ async function main() { } else if (command === 'presets') { showPresets(); } else if (command === 'setup') { + const usingDefaultHostFolder = args.length === 0; + if (usingDefaultHostFolder) { + args[0] = defaultHostDirectory(); + console.log(`Default host folder: ${args[0]} (pass an explicit directory for another host; existing installations are never reinitialized)`); + } const dir = resolve(text(args[0])); if (args.length === 1 && !existsSync(join(dir, 'setup.json'))) { await enrollmentInput(dir); return; @@ -320,9 +333,13 @@ async function main() { const stop = () => controller.abort(); process.on('SIGINT', stop); process.on('SIGTERM', stop); try { - const directory = resolve(text(args[0])), url = text(args[1]); + // Either the classic form, or the default host + // folder with the relay first (beehive host --owner-present). + const relayFirst = args.length > 0 && /^(wss|ws):\/\//.test(args[0]); + const directory = args.length === 0 || relayFirst ? defaultHostDirectory() : resolve(text(args[0])); + const url = text(args[relayFirst ? 0 : 1]); const privateInstallation = existsSync(join(directory, 'host-identity.json')); - if (privateInstallation && args[2] !== '--owner-present') throw Error('Private host requires --owner-present: OS credential access may prompt. Run deliberately as the host OS user; Stop cancels and awaits the bounded helper.'); + if (privateInstallation && args[relayFirst ? 1 : 2] !== '--owner-present') throw Error('Private host requires --owner-present: OS credential access may prompt. Run deliberately as the host OS user; Stop cancels and awaits the bounded helper.'); const credentials = privateInstallation ? { ...systemCredentials, readAsync: credentialHelperReader({ operatorApproved: true }) } : systemCredentials; const identity = privateInstallation ? await readHostIdentityAsync(directory, credentials, controller.signal) : undefined; if (identity && identity.pairing.relay !== url) throw Error('Wrong registered host relay; no network request sent'); diff --git a/beehive/test/launcher.test.ts b/beehive/test/launcher.test.ts new file mode 100644 index 0000000000..ea7915c828 --- /dev/null +++ b/beehive/test/launcher.test.ts @@ -0,0 +1,186 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { newKey, publicKey } from '../src/protocol.ts'; + +/** Launcher-focused smoke: the installed symlink must behave exactly like the + * documented `node src/cli.ts` invocation from any directory, keep explicit + * directories working, keep help public-only, and gate Node before any + * TypeScript loading. Credential-touching journeys use the established + * isolated credential fixture via NODE_OPTIONS, never the OS store. */ +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const launcherSource = join(packageRoot, 'bin', 'beehive.cjs'); +const loader = fileURLToPath(new URL('./isolated-credentials-loader.ts', import.meta.url)); +const nodeBin = dirname(process.execPath); +const baseEnv = (home: string): NodeJS.ProcessEnv => ({ PATH: `${nodeBin}:/usr/bin:/bin`, HOME: home }); +const isolatedEnv = (home: string, credentials: string): NodeJS.ProcessEnv => ({ ...baseEnv(home), BEEHIVE_TEST_CREDENTIAL_FILE: credentials, NODE_OPTIONS: `--import ${loader}` }); + +async function runLauncher(installed: string, args: string[], env: NodeJS.ProcessEnv, steps: [string, string][], cwd: string, timeoutMs = 30000): Promise<{ code: number | null; output: string; answered: number }> { + const child = spawn(installed, args, { env, cwd, stdio: ['pipe', 'pipe', 'pipe'] }); + let output = '', cursor = 0, next = 0; + const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); + child.stdout.on('data', data => { + output += data.toString(); + if (next < steps.length) { + const [prompt, answer] = steps[next]!; + const index = output.indexOf(prompt, cursor); + if (index !== -1) { cursor = index + prompt.length; next++; child.stdin.write(`${answer}\n`); } + } + }); + child.stderr.on('data', data => { output += data.toString(); }); + try { + const code = await new Promise((resolve, reject) => { child.on('error', reject); child.on('exit', resolve); }); + return { code, output: `${output}\n[answered ${next}/${steps.length} prompts]`, answered: next }; + } finally { clearTimeout(timer); child.stdin.end(); } +} + +const createJourney = (request: string, owner: string): [string, string][] => [ + ['Enrollment [', 'create'], ['Host label: ', 'launcher-host'], ['Owner PUBLIC key (hex): ', publicKey(owner)], + ['Relay URL: ', 'wss://example.invalid'], ['NEW private pairing file: ', request], +]; + +function installInto(root: string, name = 'user bin dir'): string { + const install = join(root, name); + mkdirSync(install); + const installed = join(install, 'beehive'); + symlinkSync(launcherSource, installed); + return installed; +} + +test('installed launcher prints public help from an unrelated directory with spaces and creates no state', { timeout: 20000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-launcher-')); + const home = join(root, 'home dir'); + try { + mkdirSync(home); + const installed = installInto(root); + const cwd = join(root, 'unrelated cwd dir'); + mkdirSync(cwd); + const help = await runLauncher(installed, [], baseEnv(home), [], cwd); + assert.equal(help.code, 0, help.output); + assert.match(help.output, /^Beehive — private host preview/, help.output); + assert.ok(help.output.includes('setup'), help.output); + assert.ok(help.output.includes('~/.beehive/host'), help.output); + assert.ok(!help.output.includes('Enrollment ['), help.output); + assert.ok(!existsSync(join(home, '.beehive')), 'help must not create state'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('no-arg setup enrolls on the displayed default host folder via the isolated credential fixture', { timeout: 30000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-launcher-')); + const home = join(root, 'home dir'); + try { + mkdirSync(home); + const installed = installInto(root); + const cwd = join(root, 'unrelated cwd dir'); + mkdirSync(cwd); + const owner = newKey(); + const request = join(root, 'pairing request.json'); + const credentials = join(root, 'isolated fixture secrets.json'); + const created = await runLauncher(installed, ['setup'], isolatedEnv(home, credentials), createJourney(request, owner), cwd); + assert.equal(created.code, 0, created.output); + assert.equal(created.answered, 5, created.output); + const folder = join(home, '.beehive', 'host'); + assert.ok(created.output.includes(`Default host folder: ${folder}`), created.output); + const identity = JSON.parse(readFileSync(join(folder, 'host-identity.json'), 'utf8')) as { version: number; pairing: { host: string; owner: string; label: string }; registration: unknown }; + assert.equal(identity.version, 3); + assert.equal(identity.pairing.label, 'launcher-host'); + assert.equal(identity.pairing.owner, publicKey(owner)); + assert.ok(/^[0-9a-f]{64}$/.test(identity.pairing.host)); + assert.equal(identity.registration, null); + const fixture = readFileSync(credentials, 'utf8'); + assert.ok(fixture.includes(identity.pairing.host), 'host key stored in the isolated fixture'); + assert.equal(Object.keys(JSON.parse(fixture)).length, 1); + const files = [join(folder, 'host-identity.json'), request, credentials]; + for (const file of [...files, created.output]) assert.ok(!file.includes(owner), 'owner secret never persisted or printed'); + // A second default setup must refuse to replace the retained identity. + const before = readFileSync(join(folder, 'host-identity.json'), 'utf8'); + const second = await runLauncher(installed, ['setup'], isolatedEnv(home, credentials), [ + ['Enrollment [', 'create'], ['Host label: ', 'other-host'], ['Owner PUBLIC key (hex): ', publicKey(newKey())], ['Relay URL: ', 'wss://other.example.invalid'], + ], cwd); + assert.equal(second.code, 1, second.output); + assert.ok(second.output.includes('Host identity already exists; no replacement or automatic migration'), second.output); + assert.equal(readFileSync(join(folder, 'host-identity.json'), 'utf8'), before, 'retained identity unchanged'); + assert.ok(!existsSync(join(root, 'second request.json'))); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('explicit host directory setup is preserved through the launcher and leaves the default unused', { timeout: 30000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-launcher-')); + const home = join(root, 'home dir'); + try { + mkdirSync(home); + const installed = installInto(root); + const cwd = join(root, 'unrelated cwd dir'); + mkdirSync(cwd); + const owner = newKey(); + const explicit = join(root, 'explicit host dir'); + const request = join(root, 'pairing request.json'); + const credentials = join(root, 'isolated fixture secrets.json'); + const created = await runLauncher(installed, ['setup', explicit], isolatedEnv(home, credentials), createJourney(request, owner), cwd); + assert.equal(created.code, 0, created.output); + assert.ok(!created.output.includes('Default host folder:'), created.output); + assert.ok(existsSync(join(explicit, 'host-identity.json')), 'identity lands in the explicit directory'); + assert.ok(!existsSync(join(home, '.beehive')), 'default folder untouched'); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('host startup keeps the owner-present guard on both the default folder and explicit directories', { timeout: 20000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-launcher-')); + const home = join(root, 'home dir'); + try { + mkdirSync(home); + const installed = installInto(root); + const cwd = join(root, 'unrelated cwd dir'); + mkdirSync(cwd); + const defaultFolder = join(home, '.beehive', 'host'); + mkdirSync(defaultFolder, { recursive: true }); + writeFileSync(join(defaultFolder, 'host-identity.json'), '{}', { mode: 0o600 }); + const guard = await runLauncher(installed, ['host', 'wss://example.invalid'], isolatedEnv(home, join(root, 'isolated fixture secrets.json')), [], cwd); + assert.equal(guard.code, 1, guard.output); + assert.ok(guard.output.includes('Private host requires --owner-present'), guard.output); + const explicit = join(root, 'explicit host dir'); + mkdirSync(explicit); + writeFileSync(join(explicit, 'host-identity.json'), '{}', { mode: 0o600 }); + const explicitGuard = await runLauncher(installed, ['host', explicit, 'wss://example.invalid'], isolatedEnv(home, join(root, 'isolated fixture secrets.json')), [], cwd); + assert.equal(explicitGuard.code, 1, explicitGuard.output); + assert.ok(explicitGuard.output.includes('Private host requires --owner-present'), explicitGuard.output); + // Relay-first form parses the flag and resolves the default folder offline. + const emptyHome = join(root, 'empty home dir'); + mkdirSync(emptyHome); + const missing = await runLauncher(installed, ['host', 'ws://127.0.0.1:1', '--owner-present'], isolatedEnv(emptyHome, join(root, 'isolated fixture secrets.json')), [], cwd); + assert.equal(missing.code, 1, missing.output); + assert.ok(missing.output.includes(join(emptyHome, '.beehive', 'host')), missing.output); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test('launcher Node gate: boundary comparison and a real old-node refusal', { timeout: 20000 }, async () => { + const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-launcher-')); + const home = join(root, 'home dir'); + try { + mkdirSync(home); + const probe = spawnSync(process.execPath, ['-e', "const gate = require(process.argv[1]); for (const v of process.argv.slice(2)) process.stdout.write(gate.meetsMinimumNode(v, '22.18.0') + ' ');", launcherSource, '22.18.0', '22.17.9', '18.0.0', '24.15.0', '26.8.1'], { encoding: 'utf8' }); + assert.equal(probe.status, 0, probe.stderr); + assert.equal(probe.stdout.trim(), 'true false false true true'); + // End-to-end: a PATH "node" reporting 18.9.0 must make the launcher fail + // fast with the actionable message before any TypeScript is loaded. + const stubBin = join(root, 'stub node bin'); + mkdirSync(stubBin); + const patch = join(root, 'old node patch.mjs'); + writeFileSync(patch, "Object.defineProperty(process.versions, 'node', { value: '18.9.0', configurable: true });\n"); + writeFileSync(join(stubBin, 'node'), `#!/bin/sh\nexec '${process.execPath}' --import '${patch}' "$@"\n`); + chmodSync(join(stubBin, 'node'), 0o755); + const installed = installInto(root); + const cwd = join(root, 'unrelated cwd dir'); + mkdirSync(cwd); + const refused = await runLauncher(installed, [], { PATH: `${stubBin}:${nodeBin}:/usr/bin:/bin`, HOME: home }, [], cwd); + assert.equal(refused.code, 1, refused.output); + assert.ok(refused.output.includes('beehive: Node.js 22.18.0 or newer is required'), refused.output); + assert.ok(refused.output.includes('18.9.0'), refused.output); + assert.ok(refused.output.includes('nodejs.org'), refused.output); + assert.ok(!refused.output.includes('Beehive — private host preview'), refused.output); + } finally { rmSync(root, { recursive: true, force: true }); } +});