beehive: real beehive command launcher with default host folder

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-10 15:09:20 -04:00
parent 0e5d44243c
commit 70daf9a67b
5 changed files with 298 additions and 18 deletions

View File

@@ -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 <command>` from any directory. Advanced
fallback inside the package: `/absolute/path/to/node src/cli.ts <command>`;
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 <explicit-directory>`
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 <host-directory> <relay>
--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 <full host hex>`, `show`, then
`start`. Wait for `completed | accepted` and running actual state; publication

View File

@@ -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 <directory>` without an identity file opens Beehive's
`beehive setup <directory>` (or `node src/cli.ts setup <directory>` 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.

43
beehive/bin/beehive.cjs Executable file
View File

@@ -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 };

View File

@@ -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 <command> from any directory once installed (one-time user link, reversible: ln -s <beehive-package>/bin/beehive.cjs <user bin on PATH>/beehive, e.g. ~/.local/bin); source fallback inside the package: node src/cli.ts <command>.
identity Disabled: use your existing owner signer
setup <host-directory> Offline host pairing/owner approval/import
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
setup Offline pairing/owner approval/import on the default host folder ~/.beehive/host
setup <host-directory> Explicit host folder (additional hosts/tests); same offline wizard
setup <host-directory> <identity-file> LEGACY loopback diagnostic setup (owner key copied)
provision-agent <host-directory> <binding-file> <genesis-file> Hidden matching agent import; OS credentials
reconcile-provision <host-directory> <binding-file> <genesis-file> Explicit exact first-provision recovery
catalog <new-file> <registration-files...> Retain verified public host registrations
@@ -55,9 +61,11 @@ const help = `Beehive — private host preview (explicit direct relay membership
auth-info <host-directory> [binding-id] Print local harness service context (no login)
conversation-setup <host-directory> Legacy guidance; use local-setup action normal
relay <port> <owner-public-key> <log-file> Dedicated ciphertext relay
host <host-directory> <relay> --owner-present Private foreground host; bounded OS key reads
host <relay> --owner-present Private foreground host on the default host folder; bounded OS key reads
host <host-directory> <relay> --owner-present Private foreground host on an explicit folder; bounded OS key reads
tui <catalog-file> <relay> Private owner UI; hidden existing owner signer
setup/add-agent accept optional <local-key-file> <public-genesis-file> 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 <host-directory> <relay> form, or the default host
// folder with the relay first (beehive host <relay> --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');

View File

@@ -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<number | null>((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 }); }
});