feat(setup): make installation explicit and safe

This commit is contained in:
morluto 2026-07-13 15:13:45 +08:00
parent 1d1fe7b212
commit 3793889ce8
12 changed files with 793 additions and 497 deletions

View File

@ -3,196 +3,111 @@ set -euo pipefail
PACKAGE="rea-agents"
REPOSITORY="morluto/rea"
NODE_FORMULA="node@24"
NODE_VERSION="24.18.0"
temporary_directory=""
node_destination=""
node_stage=""
node_backup=""
node_version=""
cleanup() {
if [[ -n "$temporary_directory" && -d "$temporary_directory" ]]; then
rm -rf -- "$temporary_directory"
fi
if [[ -n "$node_stage" && -e "$node_stage" ]]; then
rm -rf -- "$node_stage"
fi
if [[ -n "$node_backup" && -e "$node_backup" && ! -e "$node_destination" ]]; then
mv -- "$node_backup" "$node_destination"
fi
}
trap cleanup EXIT HUP INT TERM
version="${REA_VERSION:-}"
dry_run=false
start_setup=true
verbose=false
fail() {
printf 'REA installation failed: %s\n' "$1" >&2
exit 1
}
version_at_least() {
local actual="$1" required_major="$2" required_minor="$3"
local major minor
major="${actual%%.*}"
minor="${actual#*.}"
minor="${minor%%.*}"
[[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]] || return 1
((major > required_major || (major == required_major && minor >= required_minor)))
usage() {
cat <<'EOF'
Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose]
Installs only the REA CLI. Run `rea setup` separately to configure Hopper and coding agents.
EOF
}
platform="$(uname -s)"
if [[ "$platform" == "Darwin" ]]; then
macos_version="$(sw_vers -productVersion 2>/dev/null)" || fail "could not determine the macOS version."
macos_major="${macos_version%%.*}"
[[ "$macos_major" =~ ^[0-9]+$ && "$macos_major" -ge 12 ]] || fail "macOS 12 or newer is required."
elif [[ "$platform" == "Linux" ]]; then
[[ "$(uname -m)" == "x86_64" || "$(uname -m)" == "aarch64" ]] || fail "64-bit x86 or ARM Linux is required."
[[ -r /etc/os-release ]] || fail "could not identify the Linux distribution."
linux_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')"
linux_version="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"')"
case "$linux_id" in
ubuntu) [[ "${linux_version%%.*}" -ge 24 ]] || fail "Ubuntu 24.04 or newer is required." ;;
fedora) [[ "${linux_version%%.*}" -ge 41 ]] || fail "Fedora 41 or newer is required." ;;
arch) ;;
*) fail "supported Linux distributions are Ubuntu 24.04+, Fedora 41+, and Arch Linux." ;;
while (($# > 0)); do
case "$1" in
--version)
(($# >= 2)) || fail "--version requires a value."
version="$2"
shift 2
;;
--dry-run)
dry_run=true
shift
;;
--no-setup|--no-prompt)
start_setup=false
shift
;;
--verbose)
verbose=true
shift
;;
--help|-h)
usage
exit 0
;;
*) fail "unknown option: $1" ;;
esac
else
fail "REA supports macOS and selected 64-bit Linux distributions."
done
if [[ "$verbose" == true ]]; then
set -x
fi
platform="$(uname -s)"
[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions."
command -v curl >/dev/null 2>&1 || fail "curl is required."
command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 || fail "shasum or sha256sum is required."
command -v node >/dev/null 2>&1 || fail "Node.js 22.19+ or 24.11+ is required; install it from https://nodejs.org and retry."
command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry."
if [[ "$platform" == "Darwin" ]] && ! command -v brew >/dev/null 2>&1; then
printf 'Installing Homebrew prerequisite...\n'
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || fail "Homebrew installation did not complete."
if [[ -x /opt/homebrew/bin/brew ]]; then
PATH="/opt/homebrew/bin:$PATH"
elif [[ -x /usr/local/bin/brew ]]; then
PATH="/usr/local/bin:$PATH"
fi
fi
if [[ "$platform" == "Darwin" ]]; then
command -v brew >/dev/null 2>&1 || fail "Homebrew is not available after installation."
fi
node_compatible=false
if command -v node >/dev/null 2>&1; then
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
fi
if version_at_least "$node_version" 24 18 && [[ "$node_version" =~ ^24\. ]]; then
node_compatible=true
fi
if [[ "$node_compatible" != true ]]; then
if [[ "$platform" == "Darwin" ]]; then
brew install "$NODE_FORMULA" || fail "Node.js 24 installation failed."
node_prefix="$(brew --prefix "$NODE_FORMULA")" || fail "could not locate Node.js 24."
PATH="$node_prefix/bin:$PATH"
else
machine="$(uname -m)"
node_arch="$([[ "$machine" == "x86_64" ]] && printf 'x64' || printf 'arm64')"
node_archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz"
node_base="https://nodejs.org/dist/v${NODE_VERSION}"
temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/rea-install.XXXXXX")" || fail "could not create a temporary directory."
curl -fsSL "$node_base/$node_archive" -o "$temporary_directory/$node_archive" || fail "Node.js download failed."
curl -fsSL "$node_base/SHASUMS256.txt" -o "$temporary_directory/SHASUMS256.txt" || fail "Node.js checksum download failed."
expected_checksum="$(awk -v archive="$node_archive" '$2 == archive { print $1 }' "$temporary_directory/SHASUMS256.txt")"
[[ "$expected_checksum" =~ ^[a-f0-9]{64}$ ]] || fail "Node.js checksum metadata was malformed."
if command -v shasum >/dev/null 2>&1; then
actual_checksum="$(shasum -a 256 "$temporary_directory/$node_archive" | awk '{ print $1 }')"
else
actual_checksum="$(sha256sum "$temporary_directory/$node_archive" | awk '{ print $1 }')"
fi
[[ "$actual_checksum" == "$expected_checksum" ]] || fail "Node.js checksum verification failed."
node_destination="$HOME/.local/share/rea/node"
node_stage="$HOME/.local/share/rea/node.new.$$"
node_backup="$HOME/.local/share/rea/node.backup.$$"
[[ ! -L "$node_destination" ]] || fail "refusing to replace a symlinked Node.js installation."
mkdir -p "$HOME/.local/share/rea"
rm -rf -- "$node_stage" "$node_backup"
mkdir "$node_stage"
tar -xJf "$temporary_directory/$node_archive" --strip-components=1 -C "$node_stage" || fail "Node.js extraction failed."
if [[ -e "$node_destination" ]]; then
mv -- "$node_destination" "$node_backup"
fi
if ! mv -- "$node_stage" "$node_destination"; then
[[ ! -e "$node_backup" ]] || mv -- "$node_backup" "$node_destination"
fail "Node.js installation replacement failed."
fi
node_stage=""
rm -rf -- "$node_backup"
node_backup=""
PATH="$node_destination/bin:$PATH"
fi
fi
node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
if ! command -v node >/dev/null 2>&1 || ! version_at_least "$node_version" 24 18 || [[ ! "$node_version" =~ ^24\. ]]; then
fail "Node.js 24.18 or newer is not active on PATH."
IFS=. read -r node_major node_minor _node_patch <<<"$node_version"
[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "could not determine the active Node.js version."
if ! ((node_major == 22 && node_minor >= 19 || node_major >= 24)); then
fail "Node.js $node_version is unsupported; use Node.js 22.19+ or 24.11+."
fi
command -v npm >/dev/null 2>&1 || fail "npm is missing from the Node.js installation."
npm_version="$(npm --version)"
if [[ ! "$npm_version" =~ ^11\. ]] || ! version_at_least "$npm_version" 11 16; then
if [[ "$platform" == "Linux" ]]; then
npm install --global --prefix "$HOME/.local" npm@11 || fail "npm 11 installation failed."
else
npm install --global npm@11 || fail "npm 11 installation failed."
fi
fi
npm_version="$(npm --version)"
if [[ ! "$npm_version" =~ ^11\. ]] || ! version_at_least "$npm_version" 11 16; then
fail "npm 11.16 or newer is not active on PATH."
fi
if [[ -n "$temporary_directory" ]]; then
rm -rf -- "$temporary_directory"
temporary_directory=""
if ((node_major == 24 && node_minor < 11)); then
fail "Node.js $node_version is unsupported; use Node.js 24.11 or newer."
fi
if [[ -n "${REA_VERSION:-}" ]]; then
version="$REA_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "REA_VERSION must be an exact semantic version."
if [[ -n "$version" ]]; then
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "version must be an exact semantic version."
else
release_json="$(curl -fsSL -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "could not resolve the latest GitHub release."
release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "could not resolve the latest GitHub release."
tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})')" || fail "GitHub returned malformed release metadata."
[[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "latest release tag is not a valid rea-agents release."
version="${BASH_REMATCH[1]}"
fi
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
prefix_args=()
if [[ "$platform" == "Linux" ]]; then
mkdir -p "$HOME/.local/bin"
PATH="$HOME/.local/bin:$PATH"
npm install --global --prefix "$HOME/.local" "$PACKAGE@$version" || fail "npm package installation failed."
prefix_args=(--prefix "$HOME/.local")
install_bin="$HOME/.local/bin/rea"
else
npm install --global "$PACKAGE@$version" || fail "npm package installation failed."
npm_prefix="$(npm prefix --global)" || fail "could not determine the npm global prefix."
install_bin="$npm_prefix/bin/rea"
fi
command -v rea >/dev/null 2>&1 || fail "the rea command is not available after installation."
installed_version="$(rea --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version."
printf 'REA install plan\n'
printf ' Version: %s\n' "$version"
printf ' Runtime: Node.js %s\n' "$node_version"
printf ' Command: npm install --global %s@%s\n' "$PACKAGE" "$version"
printf ' Binary: %s\n' "$install_bin"
printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')"
if [[ "$dry_run" == true ]]; then
printf 'Dry run complete; no changes made.\n'
exit 0
fi
printf 'Installing %s@%s...\n' "$PACKAGE" "$version"
npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm package installation failed."
[[ -x "$install_bin" ]] || fail "the rea command was not installed at $install_bin."
installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version."
[[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version."
temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/rea-install.XXXXXX")" || fail "could not create a temporary directory."
setup_output="$temporary_directory/setup.json"
doctor_output="$temporary_directory/doctor.json"
rea setup --yes --json >"$setup_output" || fail "REA setup command failed."
rea doctor --json >"$doctor_output" || fail "REA doctor command failed."
result="$(node - "$setup_output" "$doctor_output" <<'NODE'
const fs = require("node:fs");
const [setupPath, doctorPath] = process.argv.slice(2);
try {
const setup = JSON.parse(fs.readFileSync(setupPath, "utf8"));
const doctor = JSON.parse(fs.readFileSync(doctorPath, "utf8"));
if (!Array.isArray(doctor.checks) || typeof doctor.healthy !== "boolean") process.exit(2);
if (setup.status !== "ready" && setup.status !== "needs_human") process.exit(2);
const activation = setup.actions.includes("installed_hopper");
if (!doctor.healthy && !activation) process.exit(3);
process.stdout.write(activation ? "activation" : "ready");
} catch { process.exit(2); }
NODE
)" || fail "setup or doctor reported an unhealthy required component or malformed output."
if [[ "$result" == "activation" ]]; then
printf 'REA %s is installed and coding agents are configured.\n' "$version"
printf 'One user action remains: open Hopper, complete its activation, then run: rea doctor --json\n'
printf 'REA %s is installed.\n' "$version"
if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then
printf 'Starting REA setup...\n'
"$install_bin" setup </dev/tty >/dev/tty
else
printf 'REA %s is installed and ready. Run rea doctor --json at any time.\n' "$version"
fi
if [[ "$platform" == "Linux" ]]; then
printf 'Add %s to PATH to run rea from future shells.\n' "$HOME/.local/bin"
printf 'Run %s setup to configure Hopper and coding agents.\n' "$install_bin"
fi

23
package-lock.json generated
View File

@ -25,7 +25,7 @@
"pino": "9.14.0",
"plist": "5.0.0",
"smol-toml": "^1.7.0",
"write-file-atomic": "8.0.0",
"write-file-atomic": "7.0.0",
"ws": "8.21.0",
"zod": "4.4.3"
},
@ -50,8 +50,7 @@
"vitest": "4.1.10"
},
"engines": {
"node": ">=24.18.0 <25",
"npm": ">=11.16.0 <12"
"node": "^22.19.0 || >=24.11.0"
}
},
"node_modules/@babel/helper-string-parser": {
@ -2814,6 +2813,15 @@
"node": ">= 4"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"license": "MIT",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/incur": {
"version": "0.4.13",
"resolved": "https://registry.npmjs.org/incur/-/incur-0.4.13.tgz",
@ -5023,15 +5031,16 @@
"license": "ISC"
},
"node_modules/write-file-atomic": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-8.0.0.tgz",
"integrity": "sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz",
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
"license": "ISC",
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
},
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/ws": {

View File

@ -33,8 +33,7 @@
"rea": "scripts/rea.mjs"
},
"engines": {
"node": ">=24.18.0 <25",
"npm": ">=11.16.0 <12"
"node": "^22.19.0 || >=24.11.0"
},
"packageManager": "npm@11.16.0",
"files": [
@ -99,7 +98,7 @@
"pino": "9.14.0",
"plist": "5.0.0",
"smol-toml": "^1.7.0",
"write-file-atomic": "8.0.0",
"write-file-atomic": "7.0.0",
"ws": "8.21.0",
"zod": "4.4.3"
},

View File

@ -324,15 +324,32 @@ try {
await writeFile(skillPath, "stale managed skill\n");
await writeFile(siblingSkillPath, "unrelated skill\n");
}
const planned = json(await run(cli, ["setup", "--json"], environment));
if (supportedSetupHost) {
if (
planned.status !== "needs_confirmation" ||
planned.appliedActions.length !== 0 ||
!planned.plannedActions.some(({ kind }) => kind === "configure_client") ||
!planned.plannedActions.some(({ kind }) => kind === "install_skill") ||
(await readFile(claudeConfig, "utf8")) !== '{"existing":true}\n' ||
(await readFile(cursorConfig, "utf8")) !== '{"existing":true}\n' ||
(await readFile(skillPath, "utf8")) !== "stale managed skill\n"
)
throw new Error("packaged setup plan mutated files before approval");
}
const first = json(await run(cli, ["setup", "--yes", "--json"], environment));
const second = json(
await run(cli, ["setup", "--yes", "--json"], environment),
await run(
cli,
["setup", "--yes", "--install-hopper", "--json"],
environment,
),
);
if (supportedSetupHost) {
if (
first.status !== "ready" ||
second.status !== "ready" ||
second.actions.length !== 0
second.appliedActions.length !== 0
)
throw new Error("packaged setup was not ready and idempotent");
for (const configPath of [claudeConfig, cursorConfig]) {
@ -467,7 +484,7 @@ try {
}
process.stdout.write(
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "idempotent" : "unsupported-host-rejected", clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true })}\n`,
`${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true })}\n`,
);
} finally {
if (tarball) await rm(join(root, tarball), { force: true });

View File

@ -7,6 +7,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import { probeHomebrew } from "./homebrew.js";
import {
linuxHopperLauncherPath,
@ -60,10 +61,9 @@ export const runDoctor = async (
readonly checks: readonly DoctorCheck[];
}> => {
const checks: DoctorCheck[] = [];
const nodeMajor = parseMajor(host.nodeVersion);
checks.push(
check("node", nodeMajor >= 24, host.nodeVersion, {
remediation: "Install Node.js 24.18 or newer.",
check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
remediation: "Install Node.js 22.19+ or 24.11+.",
classification: "missing_dependency",
}),
);
@ -107,7 +107,7 @@ export const runDoctor = async (
checks.push(
check("hopper", hopperPath !== undefined, hopperPath, {
remediation:
"Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.",
"Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
classification: "missing_analysis_engine",
}),
);

View File

@ -0,0 +1,236 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import {
access,
mkdir,
mkdtemp,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
const execFileAsync = promisify(execFile);
const RELEASES_URL =
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
const MAX_PACKAGE_BYTES = 100_000_000;
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
/** Result of installing the official Hopper macOS application. */
export type MacHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
readonly status: "failed";
readonly reason:
| "release_metadata"
| "download"
| "integrity"
| "mount"
| "bundle"
| "destination_exists"
| "copy"
| "launcher_missing";
};
/** External effects used by the bounded macOS Hopper installer. */
export interface MacHopperInstallHost {
download(
url: string,
): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }>;
createTemporaryDirectory(): Promise<string>;
createMountDirectory(path: string): Promise<void>;
writePackage(path: string, bytes: Uint8Array): Promise<void>;
mount(packagePath: string, mountPath: string): Promise<boolean>;
unmount(mountPath: string): Promise<void>;
appBundle(mountPath: string): Promise<string | undefined>;
validBundle(appPath: string): Promise<boolean>;
destinationExists(path: string): Promise<boolean>;
installBundle(
source: string,
destination: string,
stage: string,
): Promise<boolean>;
launcherReady(path: string): Promise<boolean>;
openApplication(path: string): Promise<void>;
cleanup(path: string): Promise<void>;
destination(): string;
}
/** Download, verify, and install Hopper into the current user's Applications directory. */
export const installMacHopper = async (
host: MacHopperInstallHost = systemMacHopperInstallHost(),
): Promise<MacHopperInstallResult> => {
let temporary: string | undefined;
let mounted = false;
try {
const metadata = await host.download(RELEASES_URL);
if (!metadata.ok) return { status: "failed", reason: "release_metadata" };
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
if (!parsed.success)
return { status: "failed", reason: "release_metadata" };
const release = parsed.data["OS X"];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
const archive = await host.download(release.filename);
if (!archive.ok) return { status: "failed", reason: "download" };
if (!integrityMatches(archive.bytes, release))
return { status: "failed", reason: "integrity" };
const destination = host.destination();
if (await host.destinationExists(destination))
return { status: "failed", reason: "destination_exists" };
temporary = await host.createTemporaryDirectory();
const packagePath = join(temporary, "hopper.dmg");
const mountPath = join(temporary, "mounted");
await host.createMountDirectory(mountPath);
await host.writePackage(packagePath, archive.bytes);
if (!(await host.mount(packagePath, mountPath)))
return { status: "failed", reason: "mount" };
mounted = true;
const source = await host.appBundle(mountPath);
if (source === undefined || !(await host.validBundle(source)))
return { status: "failed", reason: "bundle" };
const stage = `${destination}.rea-stage`;
if (!(await host.installBundle(source, destination, stage)))
return { status: "failed", reason: "copy" };
const launcherPath = join(destination, "Contents/MacOS/hopper");
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
await host.openApplication(destination);
return { status: "installed", launcherPath };
} catch {
return { status: "failed", reason: "download" };
} finally {
if (temporary !== undefined) {
const mountPath = join(temporary, "mounted");
if (mounted) await host.unmount(mountPath);
await host.cleanup(temporary);
}
}
};
const systemMacHopperInstallHost = (): MacHopperInstallHost => ({
async download(url) {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
signal: AbortSignal.timeout(120_000),
});
const length = Number(response.headers.get("content-length") ?? "0");
if (length > MAX_PACKAGE_BYTES)
return { ok: false, bytes: new Uint8Array() };
const bytes = new Uint8Array(await response.arrayBuffer());
return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
},
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
createMountDirectory: (path) => mkdir(path),
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
async mount(packagePath, mountPath) {
return commandSucceeds("hdiutil", [
"attach",
"-readonly",
"-nobrowse",
"-mountpoint",
mountPath,
packagePath,
]);
},
async unmount(mountPath) {
await commandSucceeds("hdiutil", ["detach", mountPath, "-force"]);
},
async appBundle(mountPath) {
const entries = await readdir(mountPath, { withFileTypes: true });
const app = entries.find(
(entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name),
);
return app === undefined ? undefined : join(mountPath, app.name);
},
async validBundle(appPath) {
if (
!(await commandSucceeds("codesign", [
"--verify",
"--deep",
"--strict",
appPath,
]))
)
return false;
try {
const identifier = (
await execFileAsync("/usr/libexec/PlistBuddy", [
"-c",
"Print :CFBundleIdentifier",
join(appPath, "Contents/Info.plist"),
])
).stdout.trim();
return identifier === BUNDLE_IDENTIFIER;
} catch {
return false;
}
},
async destinationExists(path) {
try {
await access(path);
return true;
} catch {
return false;
}
},
async installBundle(source, destination, stage) {
try {
await mkdir(join(homedir(), "Applications"), { recursive: true });
await rm(stage, { recursive: true, force: true });
await execFileAsync("ditto", [source, stage]);
await rename(stage, destination);
return true;
} catch {
await rm(stage, { recursive: true, force: true });
return false;
}
},
async launcherReady(path) {
try {
await access(path);
return true;
} catch {
return false;
}
},
async openApplication(path) {
await execFileAsync("open", [path]);
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
destination: () => join(homedir(), "Applications/Hopper Disassembler.app"),
});
const parseJson = (bytes: Uint8Array): unknown =>
JSON.parse(new TextDecoder().decode(bytes));
const integrityMatches = (
bytes: Uint8Array,
release: z.infer<typeof releaseSchema>,
): boolean =>
bytes.byteLength === Number(release.file_length) &&
bytes.byteLength <= MAX_PACKAGE_BYTES &&
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
const commandSucceeds = async (
command: string,
args: readonly string[],
): Promise<boolean> => {
try {
await execFileAsync(command, args);
return true;
} catch {
return false;
}
};
const releaseSchema = z.object({
filename: z.string().url(),
file_length: z.string().regex(/^\d+$/u),
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
});
const releasesSchema = z.object({ "OS X": releaseSchema });

View File

@ -9,22 +9,19 @@ import {
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import writeFileAtomic from "write-file-atomic";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
import { runDoctor, systemDoctorHost } from "./Doctor.js";
import { probeHomebrew } from "./homebrew.js";
import {
installLinuxHopper,
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
const execFileAsync = promisify(execFile);
import { installMacHopper } from "./MacHopper.js";
const registrationCommand = (): readonly string[] =>
process.env.npm_command === "exec"
@ -58,14 +55,12 @@ export interface SetupHost {
readonly nodeVersion: string;
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
hasHomebrew(): Promise<boolean>;
installHomebrew(): Promise<boolean>;
hopperPath(): Promise<string | undefined>;
installHopper(): Promise<boolean>;
installHopper(): Promise<string | undefined>;
detectedClients(): Promise<readonly SetupClient[]>;
configureClient(
client: SetupClient,
hopperPath: string,
hopperPath: string | undefined,
command: readonly string[],
): Promise<ClientConfigurationResult>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
@ -77,56 +72,126 @@ export interface SetupHost {
* prompt, avoiding deadlocks in stdio and other unattended environments.
*/
export interface SetupResult {
readonly status: "ready" | "needs_human";
readonly actions: readonly string[];
readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human";
readonly plannedActions: readonly SetupAction[];
readonly appliedActions: readonly string[];
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
readonly remediation?: string;
}
/** One concrete setup mutation disclosed before approval. */
export interface SetupAction {
readonly kind: "install_hopper" | "configure_client" | "install_skill";
readonly target: string;
readonly detail: string;
readonly external: boolean;
}
/** Explicit authorization supplied by an interactive or unattended CLI adapter. */
export interface SetupOptions {
readonly approved: boolean;
readonly installHopper: boolean;
readonly structured: boolean;
}
/** Adapter-owned interactive confirmation for a fully discovered setup plan. */
export type SetupConfirmation = (
actions: readonly SetupAction[],
) => Promise<boolean>;
/**
* Install prerequisites and configure detected clients idempotently.
* With `yes` false, mutations requiring consent are reported as `needs_human`;
* with `yes` true, external installers still fail closed if they require UI.
* Discovery always precedes mutation. Interactive confirmation or explicit
* unattended options authorize only the actions represented in the result.
*/
export const runSetup = async (
yes: boolean,
options: SetupOptions,
host: SetupHost = systemSetupHost(),
confirm?: SetupConfirmation,
): Promise<SetupResult> => {
const actions: string[] = [];
const appliedActions: string[] = [];
let plannedActions: readonly SetupAction[] = [];
const clients: Record<string, ClientConfigurationResult> = {};
const fail = async (remediation: string): Promise<SetupResult> => ({
status: "needs_human",
actions,
plannedActions,
appliedActions,
clients,
doctor: await host.doctor(),
remediation,
});
const unsupported = await hostRemediation(host);
if (unsupported !== undefined) return fail(unsupported);
if (host.platform === "darwin" && !(await host.hasHomebrew())) {
if (!yes) return fail("Re-run with --yes to install Homebrew.");
if (!(await host.installHomebrew()))
return fail(
"Homebrew installation was interrupted; resolve the installer prompt and re-run setup.",
);
actions.push("installed_homebrew");
}
let hopperPath = await host.hopperPath();
if (hopperPath === undefined) {
if (!yes) return fail("Re-run with --yes to install Hopper.");
if (!(await host.installHopper()))
return fail(
"Hopper installation was interrupted; resolve the system prompt and re-run setup.",
);
actions.push("installed_hopper");
hopperPath = await host.hopperPath();
if (hopperPath === undefined)
return fail(
"Hopper installation completed but its launcher was not found.",
);
const detectedClients = await host.detectedClients();
plannedActions = [
...(hopperPath === undefined
? [
{
kind: "install_hopper" as const,
target:
host.platform === "darwin"
? "~/Applications/Hopper Disassembler.app"
: "system package manager",
detail:
"Download the official Hopper package, verify it, install it, and open Hopper for activation.",
external: true,
},
]
: []),
...detectedClients
.filter(({ format }) => format !== "unsupported")
.map(
(client): SetupAction => ({
kind: "configure_client",
target: client.configPath,
detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
external: false,
}),
),
{
kind: "install_skill",
target: "~/.agents/skills/rea-analysis/SKILL.md",
detail: "Install or update the bundled REA analysis skill.",
external: false,
},
];
let approved = options.approved;
let interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) {
interactiveApproval = await confirm(plannedActions);
approved = interactiveApproval;
}
for (const client of await host.detectedClients()) {
if (!approved)
return {
status: confirm === undefined ? "needs_confirmation" : "planned",
plannedActions,
appliedActions,
clients,
doctor: await host.doctor(),
remediation:
"Review the setup plan, then rerun interactively or with --yes.",
};
if (
hopperPath === undefined &&
(interactiveApproval || options.installHopper)
) {
hopperPath = await host.installHopper();
if (hopperPath === undefined)
return {
status: "needs_human",
plannedActions,
appliedActions,
clients,
doctor: await host.doctor(),
remediation:
"Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
};
appliedActions.push("installed_hopper");
}
for (const client of detectedClients) {
const result = await host.configureClient(
client,
hopperPath,
@ -138,18 +203,19 @@ export const runSetup = async (
`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`,
);
if (result.status === "configured")
actions.push(`configured_${client.name}`);
appliedActions.push(`configured_${client.name}`);
}
const skill = await host.installSkill();
if (skill === "failed")
return fail("Agent skill installation or readback failed.");
if (skill === "installed") actions.push("installed_skill");
if (skill === "installed") appliedActions.push("installed_skill");
const doctor = await host.doctor();
const activationRequired = actions.includes("installed_hopper");
const activationRequired = appliedActions.includes("installed_hopper");
const ready = doctor.healthy && !activationRequired;
return {
status: ready ? "ready" : "needs_human",
actions,
plannedActions,
appliedActions,
clients,
doctor,
...(ready
@ -157,7 +223,9 @@ export const runSetup = async (
: {
remediation: activationRequired
? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
: "Run rea doctor and apply each reported remediation.",
: hopperPath === undefined
? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis."
: "Run rea doctor and apply each reported remediation.",
}),
};
};
@ -167,7 +235,8 @@ const hostRemediation = async (
): Promise<string | undefined> => {
if (host.platform !== "darwin" && host.platform !== "linux")
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
if (major(host.nodeVersion) < 24) return "Install Node.js 24.18 or newer.";
if (!supportsNodeVersion(host.nodeVersion))
return "Install Node.js 22.19+ or 24.11+ and rerun setup.";
if (host.platform === "darwin") {
const version = await host.macosVersion();
return version === undefined || major(version) < 12
@ -179,7 +248,7 @@ const hostRemediation = async (
: "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.";
};
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
const systemSetupHost = (): SetupHost => {
const doctorHost = systemDoctorHost();
return {
@ -187,21 +256,14 @@ const systemSetupHost = (): SetupHost => {
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution: readLinuxDistribution,
hasHomebrew: () => brewSucceeds(["--version"]),
installHomebrew: () =>
commandSucceeds(
"/bin/bash",
[
"-c",
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)",
],
{ ...process.env, NONINTERACTIVE: "1" },
),
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
installHopper: () =>
process.platform === "linux"
? installLinuxHopper().then(({ status }) => status === "installed")
: brewSucceeds(["install", "--cask", "hopper-disassembler"]),
installHopper: async () => {
const result =
process.platform === "linux"
? await installLinuxHopper()
: await installMacHopper();
return result.status === "installed" ? result.launcherPath : undefined;
},
detectedClients: () => detectClients(homedir()),
configureClient: (client, hopperPath, command) =>
client.format === "unsupported"
@ -473,24 +535,6 @@ export const installCanonicalSkill = async (
}
};
const commandSucceeds = async (
command: string,
args: readonly string[],
env?: NodeJS.ProcessEnv,
): Promise<boolean> => {
try {
await execFileAsync(command, args, env === undefined ? {} : { env });
return true;
} catch {
return false;
}
};
const brewSucceeds = async (args: readonly string[]): Promise<boolean> => {
const result = await probeHomebrew(async (command) =>
(await commandSucceeds(command, args)) ? true : undefined,
);
return result === true;
};
const major = (version: string): number =>
Number.parseInt(version.split(".")[0] ?? "0", 10);
const exists = async (path: string): Promise<boolean> => {

View File

@ -1,5 +1,6 @@
import { Cli, z } from "incur";
import { fileURLToPath } from "node:url";
import { createInterface } from "node:readline/promises";
import { runDoctor } from "./application/Doctor.js";
import {
@ -7,7 +8,7 @@ import {
runProviderAnalysis,
runSessionStatus,
} from "./application/DirectAnalysis.js";
import { runSetup } from "./application/Setup.js";
import { runSetup, type SetupAction } from "./application/Setup.js";
import { runUninstall } from "./application/Uninstall.js";
import { runUpgrade, systemUpgradeHost } from "./application/Upgrade.js";
import { PRODUCT_IDENTITY } from "./identity.js";
@ -76,11 +77,27 @@ const registerCoreCommands = (
yes: z
.boolean()
.default(false)
.describe("Approve prerequisite installation"),
.describe("Approve user-owned setup actions without prompting"),
installHopper: z
.boolean()
.default(false)
.describe("Also approve Hopper installation with --yes"),
}),
alias: { yes: "y" },
run: ({ options }) =>
logCliCommand(logger, "setup", () => runSetup(options.yes)),
alias: { yes: "y", installHopper: "install-hopper" },
run: ({ options, formatExplicit }) =>
logCliCommand(logger, "setup", () =>
runSetup(
{
approved: options.yes,
installHopper: options.installHopper,
structured: formatExplicit,
},
undefined,
options.yes || formatExplicit || process.stdin.isTTY !== true
? undefined
: confirmSetup,
),
),
});
cli.command("doctor", {
description: "Check whether REA is ready",
@ -163,6 +180,28 @@ const registerCoreCommands = (
});
};
const confirmSetup = async (
actions: readonly SetupAction[],
): Promise<boolean> => {
process.stdout.write("\nREA setup plan\n");
for (const action of actions)
process.stdout.write(
` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`,
);
const prompt = createInterface({
input: process.stdin,
output: process.stdout,
});
try {
const answer = (await prompt.question("Continue? [Y/n] "))
.trim()
.toLowerCase();
return answer === "" || answer === "y" || answer === "yes";
} finally {
prompt.close();
}
};
const registerXrefsCommand = (
cli: ReturnType<typeof Cli.create>,
logger: Logger,

View File

@ -0,0 +1,10 @@
/** Return whether a Node.js version satisfies REA's tested runtime families. */
export const supportsNodeVersion = (version: string): boolean => {
const [majorText = "", minorText = ""] = version.split(".");
const major = Number.parseInt(majorText, 10);
const minor = Number.parseInt(minorText, 10);
return (
(major === 22 && minor >= 19) ||
(major >= 24 && (major !== 24 || minor >= 11))
);
};

View File

@ -24,90 +24,54 @@ afterEach(async () => {
});
describe("curl installer scenarios", () => {
it("installs a pinned release with closed stdin and cleans temporary files", async () => {
it("installs only a pinned REA CLI with closed stdin", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, { REA_VERSION: "0.3.0" });
expect(result.stdout).toContain("REA 0.3.0 is installed and ready");
const result = await runInstaller(fixture, ["--version", "0.3.0"]);
expect(result.stdout).toContain("REA 0.3.0 is installed");
expect(result.stdout).toContain("Run ");
expect(await readFile(fixture.npmLog, "utf8")).toContain(
"rea-agents@0.3.0",
"install --global --prefix",
);
expect(await readFile(fixture.reaLog, "utf8")).toBe("--version\n");
expect(await readdir(fixture.temporary)).toEqual([]);
});
it("resolves and validates the latest rea-agents release tag", async () => {
it("accepts Node 25 without installing or replacing it", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, ["--version", "0.3.0"], {
FAKE_NODE_VERSION: "25.1.0",
});
expect(result.stdout).toContain("Runtime: Node.js 25.1.0");
});
it("resolves and validates the latest REA release tag", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture);
expect(result.stdout).toContain("Installing rea-agents@0.3.0");
expect(result.stdout).toContain("Version: 0.3.0");
});
it("bootstraps checksum-verified Node 24 into the user prefix", async () => {
it("prints a dry run without invoking npm", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, {
REA_VERSION: "0.3.0",
FAKE_OLD_NODE: "1",
FAKE_NODE_ARCHIVE: fixture.nodeArchive,
FAKE_NODE_SUMS: fixture.nodeSums,
const result = await runInstaller(fixture, [
"--version",
"0.3.0",
"--dry-run",
]);
expect(result.stdout).toContain("no changes made");
await expect(readFile(fixture.npmLog, "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
expect(result.stdout).toContain("REA 0.3.0 is installed and ready");
expect(
await readFile(
join(fixture.home, ".local/share/rea/node/bin/node"),
"utf8",
),
).toContain("24.18.0");
});
it("reports fresh Hopper activation as partial success", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture, {
REA_VERSION: "0.3.0",
FAKE_SETUP_ACTIONS: '["installed_hopper"]',
});
expect(result.stdout).toContain("One user action remains");
expect(result.stdout).toContain("open Hopper");
});
it.each([
[
"injected version",
{ REA_VERSION: "0.3.0;touch /tmp/pwned" },
"exact semantic version",
],
[
"npm failure",
{ REA_VERSION: "0.3.0", FAKE_NPM_FAIL: "1" },
"npm package installation failed",
],
[
"version mismatch",
{ REA_VERSION: "0.3.0", FAKE_REA_VERSION: "9.9.9" },
"does not match",
],
[
"malformed setup",
{ REA_VERSION: "0.3.0", FAKE_SETUP_JSON: "not-json" },
"malformed output",
],
[
"unhealthy doctor",
{ REA_VERSION: "0.3.0", FAKE_DOCTOR_HEALTHY: "false" },
"unhealthy required component",
],
["unsupported Node", { FAKE_NODE_VERSION: "20.0.0" }, "unsupported"],
["npm failure", { FAKE_NPM_FAIL: "1" }, "npm package installation failed"],
["version mismatch", { FAKE_REA_VERSION: "9.9.9" }, "does not match"],
] as const)("fails closed on %s", async (_name, overrides, message) => {
const fixture = await createFixture();
await expect(runInstaller(fixture, overrides)).rejects.toMatchObject({
stderr: expect.stringContaining(message),
});
});
it("is safe to rerun", async () => {
const fixture = await createFixture();
await runInstaller(fixture, { REA_VERSION: "0.3.0" });
await runInstaller(fixture, { REA_VERSION: "0.3.0" });
expect(
(await readFile(fixture.npmLog, "utf8")).trim().split("\n"),
).toHaveLength(2);
expect(await readdir(fixture.temporary)).toEqual([]);
await expect(
runInstaller(fixture, ["--version", "0.3.0"], overrides),
).rejects.toMatchObject({ stderr: expect.stringContaining(message) });
});
});
@ -116,8 +80,7 @@ interface InstallerFixture {
readonly bin: string;
readonly temporary: string;
readonly npmLog: string;
readonly nodeArchive: string;
readonly nodeSums: string;
readonly reaLog: string;
}
const createFixture = async (): Promise<InstallerFixture> => {
@ -127,52 +90,45 @@ const createFixture = async (): Promise<InstallerFixture> => {
const bin = join(root, "bin");
const temporary = join(root, "tmp");
const npmLog = join(root, "npm.log");
const nodeFixture = join(root, "node-v24.18.0-linux-x64");
const nodeArchive = `${nodeFixture}.tar.xz`;
const nodeSums = join(root, "SHASUMS256.txt");
const reaLog = join(root, "rea.log");
await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]);
const realNode = process.execPath;
await mkdir(join(nodeFixture, "bin"), { recursive: true });
await executable(
join(nodeFixture, "bin/node"),
`#!/bin/sh\nif [ "$1" = "-p" ]; then echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`,
);
await executable(
join(nodeFixture, "bin/npm"),
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\nexit 0\n',
);
await execFileAsync("tar", [
"-cJf",
nodeArchive,
"-C",
root,
"node-v24.18.0-linux-x64",
]);
const checksum = (
await execFileAsync("sha256sum", [nodeArchive])
).stdout.split(" ")[0];
await writeFile(nodeSums, `${checksum} node-v24.18.0-linux-x64.tar.xz\n`);
await executable(
join(bin, "uname"),
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo Linux\n',
);
await executable(
join(bin, "node"),
`#!/bin/sh\nif [ "$1" = "-p" ]; then [ "\${FAKE_OLD_NODE:-}" = "1" ] && echo 20.0.0 || echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`,
`#!/bin/sh
if [ "$1" = "-p" ]; then printf '%s\n' "\${FAKE_NODE_VERSION:-24.18.0}"; else exec ${shellQuote(process.execPath)} "$@"; fi
`,
);
await executable(
join(bin, "npm"),
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\n[ "${FAKE_NPM_FAIL:-}" = "1" ] && exit 1\nexit 0\n',
`#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_NPM_LOG"
[ "\${FAKE_NPM_FAIL:-}" = "1" ] && exit 1
prefix="$HOME/.local"
while [ "$#" -gt 0 ]; do
if [ "$1" = "--prefix" ]; then shift; prefix="$1"; fi
shift
done
mkdir -p "$prefix/bin"
cp "$FAKE_REA_SOURCE" "$prefix/bin/rea"
chmod +x "$prefix/bin/rea"
`,
);
await executable(
join(bin, "curl"),
'#!/bin/sh\nout=""\nurl=""\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then shift; out="$1"; else url="$1"; fi\n shift\ndone\ncase "$url" in\n *node-v24.18.0-linux-x64.tar.xz) cp "$FAKE_NODE_ARCHIVE" "$out" ;;\n *SHASUMS256.txt) cp "$FAKE_NODE_SUMS" "$out" ;;\n *) printf \'{"tag_name":"rea-agents-0.3.0"}\' ;;\nesac\n',
'#!/bin/sh\nprintf \'{"tag_name":"rea-agents-0.3.0"}\'\n',
);
await executable(
join(bin, "rea"),
'#!/bin/sh\ncase "$1" in\n--version) printf "%s\\n" "${FAKE_REA_VERSION:-0.3.0}" ;;\nsetup) if [ -n "${FAKE_SETUP_JSON:-}" ]; then printf "%s\\n" "$FAKE_SETUP_JSON"; else printf \'{"status":"ready","actions":%s}\\n\' "${FAKE_SETUP_ACTIONS:-[]}"; fi ;;\ndoctor) printf \'{"healthy":%s,"checks":[]}\\n\' "${FAKE_DOCTOR_HEALTHY:-true}" ;;\nesac\n',
join(bin, "rea-source"),
`#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_REA_LOG"
[ "$1" = "--version" ] && printf '%s\n' "\${FAKE_REA_VERSION:-0.3.0}"
`,
);
return { home, bin, temporary, npmLog, nodeArchive, nodeSums };
return { home, bin, temporary, npmLog, reaLog };
};
const executable = async (path: string, source: string): Promise<void> => {
@ -182,15 +138,18 @@ const executable = async (path: string, source: string): Promise<void> => {
const runInstaller = async (
fixture: InstallerFixture,
args: readonly string[] = [],
overrides: Readonly<Record<string, string>> = {},
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
execFileAsync("/bin/bash", [join(process.cwd(), "install.sh")], {
execFileAsync("/bin/bash", [join(process.cwd(), "install.sh"), ...args], {
env: {
...process.env,
HOME: fixture.home,
TMPDIR: fixture.temporary,
PATH: `${fixture.bin}:${process.env.PATH ?? ""}`,
PATH: `${fixture.bin}:/usr/bin:/bin`,
FAKE_NPM_LOG: fixture.npmLog,
FAKE_REA_LOG: fixture.reaLog,
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
...overrides,
},
});

110
tests/macHopper.test.ts Normal file
View File

@ -0,0 +1,110 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
installMacHopper,
type MacHopperInstallHost,
} from "../src/application/MacHopper.js";
class FakeMacHopperHost implements MacHopperInstallHost {
readonly archive = new TextEncoder().encode("hopper-dmg");
archiveOk = true;
valid = true;
existing = false;
mountSucceeds = true;
copySucceeds = true;
launcherSucceeds = true;
badHash = false;
unmounts = 0;
cleanups = 0;
opens = 0;
download = (url: string) =>
Promise.resolve(
url.includes("files-api")
? {
ok: true,
bytes: new TextEncoder().encode(
JSON.stringify({
"OS X": {
filename:
"https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg",
file_length: String(this.archive.byteLength),
file_hash: this.badHash
? "0".repeat(40)
: createHash("sha1").update(this.archive).digest("hex"),
},
}),
),
}
: { ok: this.archiveOk, bytes: this.archive },
);
createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test");
createMountDirectory = () => Promise.resolve();
writePackage = () => Promise.resolve();
mount = () => Promise.resolve(this.mountSucceeds);
unmount = () => {
this.unmounts += 1;
return Promise.resolve();
};
appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app");
validBundle = () => Promise.resolve(this.valid);
destinationExists = () => Promise.resolve(this.existing);
installBundle = () => Promise.resolve(this.copySucceeds);
launcherReady = () => Promise.resolve(this.launcherSucceeds);
openApplication = () => {
this.opens += 1;
return Promise.resolve();
};
cleanup = () => {
this.cleanups += 1;
return Promise.resolve();
};
destination = () => "/home/user/Applications/Hopper Disassembler.app";
}
describe("macOS Hopper installation", () => {
it("installs, verifies, opens, and cleans the official package", async () => {
const host = new FakeMacHopperHost();
await expect(installMacHopper(host)).resolves.toEqual({
status: "installed",
launcherPath:
"/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
});
expect(host.opens).toBe(1);
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
it("rejects an integrity mismatch before mounting", async () => {
const host = new FakeMacHopperHost();
host.badHash = true;
await expect(installMacHopper(host)).resolves.toEqual({
status: "failed",
reason: "integrity",
});
expect(host.unmounts).toBe(0);
expect(host.cleanups).toBe(0);
});
it("refuses to overwrite an existing application", async () => {
const host = new FakeMacHopperHost();
host.existing = true;
await expect(installMacHopper(host)).resolves.toEqual({
status: "failed",
reason: "destination_exists",
});
});
it("cleans and detaches after bundle validation fails", async () => {
const host = new FakeMacHopperHost();
host.valid = false;
await expect(installMacHopper(host)).resolves.toEqual({
status: "failed",
reason: "bundle",
});
expect(host.unmounts).toBe(1);
expect(host.cleanups).toBe(1);
});
});

View File

@ -5,26 +5,23 @@ import {
type ClientConfigurationResult,
type SetupClient,
type SetupHost,
type SetupOptions,
} from "../src/application/Setup.js";
import type { DoctorCheck } from "../src/application/Doctor.js";
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
class FakeSetupHost implements SetupHost {
readonly platform: NodeJS.Platform;
readonly nodeVersion = "24.18.0";
readonly nodeVersion = "25.1.0";
version: string | undefined = "14.5";
brew = false;
distribution: LinuxDistribution | undefined;
hopper: string | undefined;
homebrewInstallSucceeds = true;
hopperInstallSucceeds = true;
skill: "installed" | "unchanged" | "failed" = "installed";
clients: readonly SetupClient[] = [];
clientResults = new Map<string, ClientConfigurationResult>();
homebrewInstalls = 0;
hopperInstalls = 0;
configurations = 0;
configuredHopperPaths: string[] = [];
distribution: LinuxDistribution | undefined;
constructor(platform: NodeJS.Platform = "darwin") {
this.platform = platform;
@ -34,31 +31,20 @@ class FakeSetupHost implements SetupHost {
Promise.resolve(this.version);
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distribution);
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
installHomebrew = (): Promise<boolean> => {
this.homebrewInstalls += 1;
if (this.homebrewInstallSucceeds) this.brew = true;
return Promise.resolve(this.homebrewInstallSucceeds);
};
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
installHopper = (): Promise<boolean> => {
installHopper = (): Promise<string | undefined> => {
this.hopperInstalls += 1;
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
return Promise.resolve(this.hopperInstallSucceeds);
return Promise.resolve(this.hopper);
};
detectedClients = (): Promise<readonly SetupClient[]> =>
Promise.resolve(this.clients);
configureClient = (
client: SetupClient,
hopperPath: string,
): Promise<ClientConfigurationResult> => {
this.configurations += 1;
this.configuredHopperPaths.push(hopperPath);
return Promise.resolve(
this.clientResults.get(client.name) ?? {
status: "configured",
backupPath: `${client.configPath}.backup`,
},
this.clientResults.get(client.name) ?? { status: "configured" },
);
};
installSkill = (): Promise<"installed" | "unchanged" | "failed"> =>
@ -69,40 +55,107 @@ class FakeSetupHost implements SetupHost {
checks: readonly DoctorCheck[];
}> =>
Promise.resolve({
healthy:
(this.platform === "linux" || this.brew) && this.hopper !== undefined,
healthy: this.hopper !== undefined,
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
checks: [],
});
}
const options = (approved: boolean, installHopper = false): SetupOptions => ({
approved,
installHopper,
structured: true,
});
describe("setup workflow", () => {
it("installs Homebrew and Hopper on a clean approved machine", async () => {
it("returns a complete plan without mutation", async () => {
const host = new FakeSetupHost();
const result = await runSetup(true, host);
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(options(false), host);
expect(result.status).toBe("needs_confirmation");
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
"install_hopper",
"configure_client",
"install_skill",
]);
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
});
it("applies an accepted interactive plan including Hopper", async () => {
const host = new FakeSetupHost();
const result = await runSetup(
{ ...options(false), structured: false },
host,
() => Promise.resolve(true),
);
expect(result.status).toBe("needs_human");
expect(result.actions).toEqual([
"installed_homebrew",
expect(result.appliedActions).toEqual([
"installed_hopper",
"installed_skill",
]);
expect(host.hopperInstalls).toBe(1);
});
it("installs Hopper without Homebrew on supported Linux", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
};
const result = await runSetup(true, host);
it("declines an interactive plan without mutation", async () => {
const host = new FakeSetupHost();
const result = await runSetup(
{ ...options(false), structured: false },
host,
() => Promise.resolve(false),
);
expect(result.status).toBe("planned");
expect(result.appliedActions).toEqual([]);
expect(host.hopperInstalls).toBe(0);
});
it("requires the Hopper flag for unattended setup", async () => {
const host = new FakeSetupHost();
const result = await runSetup(options(true), host);
expect(result.status).toBe("needs_human");
expect(result.actions).toEqual(["installed_hopper", "installed_skill"]);
expect(host.homebrewInstalls).toBe(0);
expect(host.hopperInstalls).toBe(0);
expect(result.appliedActions).toEqual(["installed_skill"]);
expect(result.remediation).toContain("--install-hopper");
});
it("rejects unsupported Linux before mutation", async () => {
it("installs Hopper when unattended authorization is explicit", async () => {
const host = new FakeSetupHost();
const result = await runSetup(options(true, true), host);
expect(result.appliedActions).toEqual([
"installed_hopper",
"installed_skill",
]);
expect(host.hopperInstalls).toBe(1);
});
it("reuses existing Hopper and configures detected clients", async () => {
const host = new FakeSetupHost();
host.hopper = "/Applications/Hopper";
host.clients = [
{ name: "claude", configPath: "/claude.json" },
{ name: "cursor", configPath: "/cursor.json" },
];
const result = await runSetup(options(true), host);
expect(result.status).toBe("ready");
expect(result.appliedActions).toEqual([
"configured_claude",
"configured_cursor",
"installed_skill",
]);
expect(host.hopperInstalls).toBe(0);
});
it("reports a failed Hopper installation without configuring clients", async () => {
const host = new FakeSetupHost();
host.hopperInstallSucceeds = false;
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain("failed");
expect(host.configurations).toBe(0);
});
it("rejects unsupported hosts before mutation", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "debian",
@ -110,103 +163,8 @@ describe("setup workflow", () => {
packageFamily: "deb",
supported: false,
};
const result = await runSetup(true, host);
const result = await runSetup(options(true, true), host);
expect(result.status).toBe("needs_human");
expect(host.hopperInstalls).toBe(0);
expect(host.configurations).toBe(0);
});
it("requires --yes before installing Homebrew", async () => {
const host = new FakeSetupHost();
const result = await runSetup(false, host);
expect(result.status).toBe("needs_human");
expect(host.homebrewInstalls).toBe(0);
expect(result.remediation).toContain("--yes");
});
it("requires --yes before installing Hopper", async () => {
const host = new FakeSetupHost();
host.brew = true;
const result = await runSetup(false, host);
expect(result.status).toBe("needs_human");
expect(host.hopperInstalls).toBe(0);
expect(result.remediation).toContain("--yes");
});
it("accepts existing Homebrew and manually installed Hopper", async () => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Applications/Manual Hopper";
expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]);
expect(host.hopperInstalls).toBe(0);
expect(host.configuredHopperPaths).toEqual([]);
});
it.each(["homebrew", "hopper"] as const)(
"returns needs_human when %s installation is interrupted",
async (stage) => {
const host = new FakeSetupHost();
if (stage === "homebrew") host.homebrewInstallSucceeds = false;
else {
host.brew = true;
host.hopperInstallSucceeds = false;
}
const result = await runSetup(true, host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain("interrupted");
},
);
it("configures every detected client and reports backup paths", async () => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Hopper";
host.clients = [
{ name: "claude", configPath: "/claude.json" },
{ name: "cursor", configPath: "/cursor.json" },
];
const result = await runSetup(true, host);
expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]);
expect(host.configurations).toBe(2);
expect(host.configuredHopperPaths).toEqual(["/Hopper", "/Hopper"]);
});
it.each(["backup", "write", "readback"] as const)(
"stops with precise remediation after a %s failure",
async (reason) => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Hopper";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.clientResults.set("cursor", { status: "failed", reason });
const result = await runSetup(true, host);
expect(result.status).toBe("needs_human");
expect(result.remediation).toContain(reason);
},
);
it("is idempotent on repeated setup", async () => {
const host = new FakeSetupHost();
host.brew = true;
host.hopper = "/Hopper";
host.skill = "unchanged";
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
host.clientResults.set("cursor", { status: "unchanged" });
const first = await runSetup(true, host);
const second = await runSetup(true, host);
expect(first.actions).toEqual([]);
expect(second.actions).toEqual([]);
expect(host.homebrewInstalls).toBe(0);
expect(host.hopperInstalls).toBe(0);
});
it.each(["11.7", undefined])(
"rejects unsupported macOS %s before mutation",
async (version) => {
const host = new FakeSetupHost();
host.version = version;
expect((await runSetup(true, host)).status).toBe("needs_human");
expect(host.homebrewInstalls).toBe(0);
},
);
});