feat: add cross-platform installation lifecycle

This commit is contained in:
morluto 2026-07-12 23:42:20 +00:00
parent af9794158d
commit e651cb65db
19 changed files with 1630 additions and 110 deletions

198
install.sh Executable file
View File

@ -0,0 +1,198 @@
#!/bin/bash
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
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)))
}
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." ;;
esac
else
fail "REA supports macOS and selected 64-bit Linux distributions."
fi
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."
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."
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=""
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."
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."
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"
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."
else
npm install --global "$PACKAGE@$version" || fail "npm package installation failed."
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."
[[ "$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'
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"
fi

2
package-lock.json generated
View File

@ -24,6 +24,7 @@
"node-pty": "1.1.0",
"pino": "9.14.0",
"plist": "5.0.0",
"smol-toml": "^1.7.0",
"write-file-atomic": "8.0.0",
"ws": "8.21.0",
"zod": "4.4.3"
@ -4462,7 +4463,6 @@
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
"integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"

View File

@ -39,6 +39,7 @@
"packageManager": "npm@11.16.0",
"files": [
"dist",
"install.sh",
"bridge/hopper_bridge.py",
"scripts/rea.mjs",
"scripts/rebuild-native.mjs",
@ -66,6 +67,7 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"verify:hopper:linux": "npm run build && node scripts/verify-real-hopper-linux.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
@ -96,6 +98,7 @@
"node-pty": "1.1.0",
"pino": "9.14.0",
"plist": "5.0.0",
"smol-toml": "^1.7.0",
"write-file-atomic": "8.0.0",
"ws": "8.21.0",
"zod": "4.4.3"

View File

@ -3,6 +3,10 @@
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while only dist/main.js may serve the 68-tool stdio server.
const args = process.argv.slice(2);
const { default: packageJson } = await import("../package.json", {
with: { type: "json" },
});
process.env.REA_PACKAGE_VERSION = packageJson.version;
const isMcpMode =
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");

View File

@ -114,7 +114,9 @@ try {
const help = await run(cli, ["--help"], environment);
const llms = await run(cli, ["--llms"], environment);
const doctor = json(await run(cli, ["doctor", "--json"], environment));
const expectedDoctorHealth = process.platform === "darwin";
const supportedSetupHost =
doctor.checks?.find(({ name }) => name === "host")?.ok === true;
const expectedDoctorHealth = supportedSetupHost;
if (
!help.includes("setup") ||
!help.includes("inventory-artifact") ||
@ -213,7 +215,7 @@ try {
throw new Error("Incur mcp add did not register the floating npx command");
const skillPath = join(home, ".agents/skills/rea-analysis/SKILL.md");
const siblingSkillPath = join(home, ".agents/skills/unrelated/SKILL.md");
if (process.platform === "darwin") {
if (supportedSetupHost) {
await mkdir(join(home, ".agents/skills/rea-analysis"), {
recursive: true,
});
@ -225,7 +227,7 @@ try {
const second = json(
await run(cli, ["setup", "--yes", "--json"], environment),
);
if (process.platform === "darwin") {
if (supportedSetupHost) {
if (
first.status !== "ready" ||
second.status !== "ready" ||
@ -236,9 +238,8 @@ try {
const config = json(await readFile(configPath, "utf8"));
if (
config.existing !== true ||
config.mcpServers?.rea?.command !== "npx" ||
JSON.stringify(config.mcpServers?.rea?.args) !==
JSON.stringify(["-y", "rea-agents", "mcp"])
config.mcpServers?.rea?.command !== cli ||
JSON.stringify(config.mcpServers?.rea?.args) !== JSON.stringify(["mcp"])
)
throw new Error("packaged client readback failed");
if (
@ -297,46 +298,50 @@ try {
const client = new Client({ name: "package-smoke", version: "1.0.0" });
try {
await client.connect(transport);
if ((await client.listTools()).tools.length !== expectedToolCount)
const mcpOptions = { timeout: 15_000 };
if (
(await client.listTools(undefined, mcpOptions)).tools.length !==
expectedToolCount
)
throw new Error("packaged MCP tool inventory diverged from contracts");
const result = await client.callTool({
name: "current_document",
arguments: {},
});
const result = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (result.isError !== true)
throw new Error("packaged target-free MCP omitted no-target error");
const opened = await client.callTool({
name: "open_binary",
arguments: { path: process.execPath },
});
const opened = await client.callTool(
{ name: "open_binary", arguments: { path: process.execPath } },
mcpOptions,
);
if (opened.isError === true)
throw new Error("packaged MCP could not open a binary");
const overviewResult = await client.callTool({
name: "binary_overview",
arguments: {},
});
const current = await client.callTool(
{ name: "current_document", arguments: {} },
mcpOptions,
);
if (
overviewResult.isError === true ||
json(text(overviewResult)).normalized_result?.procedure_count < 1
current.isError === true ||
json(text(current)).normalized_result !== "fixture"
)
throw new Error("packaged MCP analysis workflow failed");
const closed = await client.callTool({
name: "close_binary",
arguments: {},
});
throw new Error("packaged MCP bridge call failed");
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
mcpOptions,
);
if (closed.isError === true)
throw new Error("packaged MCP could not close its binary");
const mcpBundlePath = join(evidenceRoot, "mcp.json");
const mcpExport = await client.callTool({
name: "export_evidence_bundle",
arguments: { path: mcpBundlePath },
});
const mcpExport = await client.callTool(
{ name: "export_evidence_bundle", arguments: { path: mcpBundlePath } },
mcpOptions,
);
if (mcpExport.isError === true)
throw new Error("packaged MCP evidence export failed");
const mcpImport = await client.callTool({
name: "import_evidence_bundle",
arguments: { path: mcpBundlePath },
});
const mcpImport = await client.callTool(
{ name: "import_evidence_bundle", arguments: { path: mcpBundlePath } },
mcpOptions,
);
if (mcpImport.isError === true)
throw new Error("packaged MCP evidence import failed");
} catch (cause) {
@ -347,7 +352,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: process.platform === "darwin" ? "idempotent" : "unsupported-host-rejected", clients: process.platform === "darwin" ? 2 : 0, backupReadback: process.platform === "darwin", failureRecovery: process.platform === "darwin", skill: process.platform === "darwin", 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 ? "idempotent" : "unsupported-host-rejected", 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

@ -0,0 +1,22 @@
import { access } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
if (process.platform !== "linux")
throw new Error("Linux real-Hopper verification must run on Linux");
const launcher = process.env.HOPPER_LAUNCHER_PATH ?? "/opt/hopper/bin/Hopper";
await access(launcher);
const linked = await execFileAsync("ldd", [launcher]);
if (`${linked.stdout}\n${linked.stderr}`.includes("not found"))
throw new Error("Hopper has unresolved shared-library dependencies");
if (
process.env.DISPLAY === undefined &&
process.env.WAYLAND_DISPLAY === undefined
)
throw new Error(
"Linux real-Hopper verification requires DISPLAY or WAYLAND_DISPLAY",
);
process.env.HOPPER_LAUNCHER_PATH = launcher;
await import("./verify-real-hopper.mjs");

View File

@ -8,15 +8,28 @@ import { promisify } from "node:util";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import { probeHomebrew } from "./homebrew.js";
import {
linuxHopperLauncherPath,
linuxSharedLibrariesAvailable,
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
const execFileAsync = promisify(execFile);
const DEFAULT_HOPPER =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
/** One actionable environment diagnostic. */
export interface DoctorCheck {
readonly name: string;
readonly ok: boolean;
readonly classification:
| "healthy"
| "missing_dependency"
| "unsupported_host"
| "missing_analysis_engine"
| "config_drift";
readonly detail?: string;
readonly remediation?: string;
}
@ -26,6 +39,7 @@ export interface DoctorHost {
readonly nodeVersion: string;
readonly configuredHopperPath?: string;
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
validTarget(path: string): Promise<boolean>;
executable(path: string): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
@ -48,27 +62,39 @@ export const runDoctor = async (
const checks: DoctorCheck[] = [];
const nodeMajor = parseMajor(host.nodeVersion);
checks.push(
check(
"node",
nodeMajor >= 22,
host.nodeVersion,
"Install Node.js 22 or newer.",
),
check("node", nodeMajor >= 24, host.nodeVersion, {
remediation: "Install Node.js 24.18 or newer.",
classification: "missing_dependency",
}),
);
const macosVersion =
host.platform === "darwin" ? await host.macosVersion() : undefined;
const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
const linuxDistribution =
host.platform === "linux" ? await host.linuxDistribution() : undefined;
const supportedHost =
(host.platform === "darwin" && macosMajor >= 12) ||
(host.platform === "linux" && linuxDistribution?.supported === true);
checks.push(
check(
"macos",
host.platform === "darwin" && macosMajor >= 12,
macosVersion ?? host.platform,
"Hopper requires macOS 12 or newer.",
"host",
supportedHost,
macosVersion ??
(linuxDistribution === undefined
? host.platform
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`),
{
remediation:
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
classification: "unsupported_host",
},
),
);
const candidates = [
host.configuredHopperPath,
DEFAULT_HOPPER,
SYSTEM_LINUX_HOPPER,
linuxHopperLauncherPath(homedir()),
...(await host.manualHopperPaths()),
await host.brewHopperPath(),
].filter((value): value is string => value !== undefined);
@ -79,21 +105,18 @@ export const runDoctor = async (
break;
}
checks.push(
check(
"hopper",
hopperPath !== undefined,
hopperPath,
"Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH.",
),
check("hopper", hopperPath !== undefined, hopperPath, {
remediation:
"Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.",
classification: "missing_analysis_engine",
}),
);
if (target !== undefined)
checks.push(
check(
"target",
await host.validTarget(target),
target,
"Supply a readable local app or program path.",
),
check("target", await host.validTarget(target), target, {
remediation: "Supply a readable local app or program path.",
classification: "config_drift",
}),
);
return {
healthy: checks.every(({ ok }) => ok),
@ -118,12 +141,20 @@ export const systemDoctorHost = (): DoctorHost => ({
return undefined;
}
},
linuxDistribution: readLinuxDistribution,
async validTarget(path) {
return (await parseBinaryTarget(path)).ok;
},
async executable(path) {
try {
await access(path, constants.X_OK);
if (process.platform === "linux") {
const linked = await execFileAsync("ldd", [path]);
if (
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
)
return false;
}
return true;
} catch {
return false;
@ -168,10 +199,14 @@ const check = (
name: string,
ok: boolean,
detail: string | undefined,
remediation: string,
failure: {
readonly remediation: string;
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
},
): DoctorCheck => ({
name,
ok,
classification: ok ? "healthy" : failure.classification,
...(detail === undefined ? {} : { detail }),
...(ok ? {} : { remediation }),
...(ok ? {} : { remediation: failure.remediation }),
});

View File

@ -0,0 +1,273 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { 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;
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
/** Parsed Linux distribution information used for support and package selection. */
export interface LinuxDistribution {
readonly id: string;
readonly versionId?: string;
readonly packageFamily: LinuxPackageFamily | undefined;
readonly supported: boolean;
}
/** Download response kept independent of Node's global fetch implementation. */
export interface LinuxHopperDownload {
readonly ok: boolean;
readonly bytes: Uint8Array;
}
/** External effects required to install the official Linux package. */
export interface LinuxHopperInstallHost {
distribution(): Promise<LinuxDistribution | undefined>;
download(
url: string,
options: { readonly signal?: AbortSignal },
): Promise<LinuxHopperDownload>;
createTemporaryDirectory(): Promise<string>;
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
launcherReady(path: string): Promise<boolean>;
cleanup(path: string): Promise<void>;
}
/** Structured outcome for every expected Linux installation failure. */
export type LinuxHopperInstallResult =
| { readonly status: "installed"; readonly launcherPath: string }
| {
readonly status: "failed";
readonly reason:
| "unsupported_host"
| "release_metadata"
| "download"
| "integrity"
| "authorization_or_package_manager"
| "launcher_missing"
| "cancelled";
};
/** Parse an os-release document without executing its shell syntax. */
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
const fields = new Map<string, string>();
for (const line of text.split("\n")) {
const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim());
if (match === null) continue;
const key = match[1];
const encoded = match[2];
if (key === undefined || encoded === undefined) continue;
const value =
encoded.startsWith('"') && encoded.endsWith('"')
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
: encoded;
fields.set(key, value);
}
const id = (fields.get("ID") ?? "unknown").toLowerCase();
const versionId = fields.get("VERSION_ID");
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
const supported =
id === "arch" ||
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
(id === "fedora" && versionAtLeast(versionId, 41));
return {
id,
...(versionId === undefined ? {} : { versionId }),
packageFamily,
supported,
};
};
/** Read and classify the current Linux distribution. */
export const readLinuxDistribution = async (): Promise<
LinuxDistribution | undefined
> => {
try {
return parseLinuxDistribution(await readFile("/etc/os-release", "utf8"));
} catch {
return undefined;
}
};
/** Download, verify, install, and read back the official Hopper Linux package. */
export const installLinuxHopper = async (
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(),
options: { readonly signal?: AbortSignal } = {},
): Promise<LinuxHopperInstallResult> => {
if (options.signal?.aborted === true)
return { status: "failed", reason: "cancelled" };
const distribution = await host.distribution();
if (
distribution?.supported !== true ||
distribution.packageFamily === undefined
)
return { status: "failed", reason: "unsupported_host" };
let temporary: string | undefined;
try {
const metadataDownload = await host.download(RELEASES_URL, options);
if (!metadataDownload.ok)
return { status: "failed", reason: "release_metadata" };
const releases = parseReleases(metadataDownload.bytes);
if (releases === undefined)
return { status: "failed", reason: "release_metadata" };
const release = releases[releaseKey(distribution.packageFamily)];
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
return { status: "failed", reason: "release_metadata" };
const archiveDownload = await host.download(release.filename, options);
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
if (!packageIntegrityMatches(archiveDownload.bytes, release))
return { status: "failed", reason: "integrity" };
temporary = await host.createTemporaryDirectory();
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
await host.writeArchive(archive, archiveDownload.bytes);
if (!(await host.installPackage(distribution.packageFamily, archive)))
return { status: "failed", reason: "authorization_or_package_manager" };
const launcherPath = "/opt/hopper/bin/Hopper";
if (!(await host.launcherReady(launcherPath)))
return { status: "failed", reason: "launcher_missing" };
return { status: "installed", launcherPath };
} catch (cause: unknown) {
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
return { status: "failed", reason: "download" };
} finally {
if (temporary !== undefined) await host.cleanup(temporary);
}
};
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
export const linuxHopperLauncherPath = (home: string): string =>
join(home, ".local/share/rea/hopper/bin/Hopper");
const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
distribution: readLinuxDistribution,
async download(url, options) {
const response = await fetch(url, {
headers: { accept: "application/json", "user-agent": "rea-installer" },
...(options.signal === undefined ? {} : { signal: options.signal }),
});
return {
ok: response.ok,
bytes: new Uint8Array(await response.arrayBuffer()),
};
},
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
installPackage: installSystemPackage,
async launcherReady(path) {
try {
await access(path);
const linked = await execFileAsync("ldd", [path]);
return linuxSharedLibrariesAvailable(
`${linked.stdout}\n${linked.stderr}`,
);
} catch {
return false;
}
},
cleanup: (path) => rm(path, { recursive: true, force: true }),
});
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
export const linuxSharedLibrariesAvailable = (output: string): boolean =>
!/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
const installSystemPackage = async (
family: LinuxPackageFamily,
archive: string,
): Promise<boolean> => {
try {
const command = linuxPackageManagerCommand(
family,
archive,
process.getuid?.() === 0,
);
await execFileAsync(command.executable, command.args);
return true;
} catch {
return false;
}
};
/** Select the native package manager and authorization boundary without shell evaluation. */
export const linuxPackageManagerCommand = (
family: LinuxPackageFamily,
archive: string,
isRoot: boolean,
): { readonly executable: string; readonly args: readonly string[] } => {
const command =
family === "deb"
? ["apt-get", "install", "-y", archive]
: family === "rpm"
? ["dnf", "install", "-y", archive]
: ["pacman", "-U", "--noconfirm", archive];
const executable = isRoot ? command[0] : "pkexec";
if (executable === undefined) throw new Error("package command is empty");
return { executable, args: isRoot ? command.slice(1) : command };
};
const parseReleases = (
bytes: Uint8Array,
): z.infer<typeof releasesSchema> | undefined => {
try {
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
const result = releasesSchema.safeParse(parsed);
return result.success ? result.data : undefined;
} catch {
return undefined;
}
};
const packageIntegrityMatches = (
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 packageFamilyFor = (
id: string,
idLike: string | undefined,
): LinuxPackageFamily | undefined => {
const identities = new Set([
id,
...(idLike?.toLowerCase().split(/\s+/u) ?? []),
]);
if (identities.has("ubuntu") || identities.has("debian")) return "deb";
if (identities.has("fedora") || identities.has("rhel")) return "rpm";
if (identities.has("arch")) return "arch";
return undefined;
};
const versionAtLeast = (
version: string | undefined,
minimum: number,
): boolean =>
version !== undefined &&
Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
const releaseKey = (
family: LinuxPackageFamily,
): "Ubuntu / Mint" | "Fedora" | "Arch" =>
family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
const isAbortCause = (cause: unknown): boolean =>
cause instanceof Error && cause.name === "AbortError";
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({
"Ubuntu / Mint": releaseSchema,
Fedora: releaseSchema,
Arch: releaseSchema,
});

View File

@ -8,27 +8,40 @@ import {
writeFile,
} from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
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 { runDoctor, systemDoctorHost } from "./Doctor.js";
import { probeHomebrew } from "./homebrew.js";
import {
installLinuxHopper,
readLinuxDistribution,
type LinuxDistribution,
} from "./LinuxHopper.js";
const execFileAsync = promisify(execFile);
const registrationCommand = (): readonly string[] =>
process.env.npm_command === "exec"
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
/** A detected agent configuration owned by setup. */
export interface SetupClient {
readonly name: string;
readonly configPath: string;
readonly markerPath?: string;
readonly format?: "json" | "toml" | "unsupported";
}
/** Result of one backup/write/readback transaction. */
export type ClientConfigurationResult =
| {
readonly status: "unchanged" | "configured";
readonly status: "unchanged" | "configured" | "skipped";
readonly backupPath?: string;
}
| {
@ -44,6 +57,7 @@ export interface SetupHost {
readonly platform: NodeJS.Platform;
readonly nodeVersion: string;
macosVersion(): Promise<string | undefined>;
linuxDistribution(): Promise<LinuxDistribution | undefined>;
hasHomebrew(): Promise<boolean>;
installHomebrew(): Promise<boolean>;
hopperPath(): Promise<string | undefined>;
@ -52,6 +66,7 @@ export interface SetupHost {
configureClient(
client: SetupClient,
hopperPath: string,
command: readonly string[],
): Promise<ClientConfigurationResult>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
@ -87,13 +102,9 @@ export const runSetup = async (
doctor: await host.doctor(),
remediation,
});
if (host.platform !== "darwin")
return fail("Hopper requires macOS 12 or newer.");
const macosVersion = await host.macosVersion();
if (macosVersion === undefined || major(macosVersion) < 12)
return fail("Upgrade to macOS 12 or newer.");
if (major(host.nodeVersion) < 22) return fail("Install Node.js 22 or newer.");
if (!(await host.hasHomebrew())) {
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(
@ -116,7 +127,11 @@ export const runSetup = async (
);
}
for (const client of await host.detectedClients()) {
const result = await host.configureClient(client, hopperPath);
const result = await host.configureClient(
client,
hopperPath,
registrationCommand(),
);
clients[client.name] = result;
if (result.status === "failed")
return fail(
@ -130,19 +145,40 @@ export const runSetup = async (
return fail("Agent skill installation or readback failed.");
if (skill === "installed") actions.push("installed_skill");
const doctor = await host.doctor();
const activationRequired = actions.includes("installed_hopper");
const ready = doctor.healthy && !activationRequired;
return {
status: doctor.healthy ? "ready" : "needs_human",
status: ready ? "ready" : "needs_human",
actions,
clients,
doctor,
...(doctor.healthy
...(ready
? {}
: {
remediation: "Run rea doctor and apply each reported remediation.",
remediation: activationRequired
? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
: "Run rea doctor and apply each reported remediation.",
}),
};
};
const hostRemediation = async (
host: SetupHost,
): 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 (host.platform === "darwin") {
const version = await host.macosVersion();
return version === undefined || major(version) < 12
? "Upgrade to macOS 12 or newer."
: undefined;
}
return (await host.linuxDistribution())?.supported === true
? undefined
: "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. */
const systemSetupHost = (): SetupHost => {
const doctorHost = systemDoctorHost();
@ -150,6 +186,7 @@ const systemSetupHost = (): SetupHost => {
platform: process.platform,
nodeVersion: process.versions.node,
macosVersion: () => doctorHost.macosVersion(),
linuxDistribution: readLinuxDistribution,
hasHomebrew: () => brewSucceeds(["--version"]),
installHomebrew: () =>
commandSucceeds(
@ -162,43 +199,86 @@ const systemSetupHost = (): SetupHost => {
),
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
installHopper: () =>
brewSucceeds(["install", "--cask", "hopper-disassembler"]),
detectedClients: () => detectJsonClients(homedir()),
configureClient: configureJsonClient,
process.platform === "linux"
? installLinuxHopper().then(({ status }) => status === "installed")
: brewSucceeds(["install", "--cask", "hopper-disassembler"]),
detectedClients: () => detectClients(homedir()),
configureClient: (client, hopperPath, command) =>
client.format === "unsupported"
? Promise.resolve({ status: "skipped" })
: client.format === "toml"
? configureTomlClient(client, hopperPath, command)
: configureJsonClient(client, hopperPath, command),
installSkill: () => installCanonicalSkill(homedir()),
doctor: () => runDoctor(undefined, doctorHost),
};
};
const detectJsonClients = async (
/** Detect supported coding agents from stable per-user installation markers. */
export const detectClients = async (
home: string,
): Promise<readonly SetupClient[]> => {
const candidates = [
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
marker: join(home, "Library/Application Support/Claude"),
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
marker: join(home, ".cursor"),
},
];
const detected: SetupClient[] = [];
for (const candidate of candidates)
if (await exists(candidate.marker))
detected.push({ name: candidate.name, configPath: candidate.configPath });
for (const candidate of supportedClients(home))
if (await exists(candidate.markerPath ?? candidate.configPath))
detected.push(candidate);
return detected;
};
/** Describe every client location that setup or uninstall may own. */
export const supportedClients = (home: string): readonly SetupClient[] => [
{
name: "claude_code",
configPath: join(home, ".claude.json"),
markerPath: join(home, ".claude"),
},
{
name: "claude_desktop",
configPath: join(
home,
"Library/Application Support/Claude/claude_desktop_config.json",
),
markerPath: join(home, "Library/Application Support/Claude"),
},
{
name: "codex",
configPath: join(home, ".codex/config.toml"),
markerPath: join(home, ".codex"),
format: "toml" as const,
},
{
name: "cursor",
configPath: join(home, ".cursor/mcp.json"),
markerPath: join(home, ".cursor"),
},
{
name: "gemini_cli",
configPath: join(home, ".gemini/settings.json"),
markerPath: join(home, ".gemini"),
},
{
name: "windsurf",
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
markerPath: join(home, ".codeium/windsurf"),
},
{
name: "devin",
configPath: join(home, ".devin"),
markerPath: join(home, ".devin"),
format: "unsupported" as const,
},
];
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
hopperPath?: string,
command: readonly string[] = [
"npx",
"-y",
PRODUCT_IDENTITY.packageName,
"mcp",
],
): Promise<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
let original: string | undefined;
@ -215,8 +295,8 @@ export const configureJsonClient = async (
return { status: "failed", reason: "readback" };
}
const desired = {
command: "npx",
args: ["-y", PRODUCT_IDENTITY.packageName, "mcp"],
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
@ -268,6 +348,81 @@ export const configureJsonClient = async (
};
};
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
export const configureTomlClient = async (
client: SetupClient,
hopperPath?: string,
command: readonly string[] = [
"npx",
"-y",
PRODUCT_IDENTITY.packageName,
"mcp",
],
): Promise<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
let original: string | undefined;
try {
original = await readFile(client.configPath, "utf8");
document = objectSchema.parse(parseToml(original));
} catch (cause: unknown) {
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
}
let servers: Record<string, unknown>;
try {
servers = parseOptionalObject(document.mcp_servers);
} catch {
return { status: "failed", reason: "readback" };
}
const desired = {
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
args: command.slice(1),
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
};
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
JSON.stringify(desired)
)
return { status: "unchanged" };
const backupPath =
original === undefined ? undefined : `${client.configPath}.rea.backup`;
try {
if (backupPath !== undefined) await copyFile(client.configPath, backupPath);
} catch {
return { status: "failed", reason: "backup" };
}
document.mcp_servers = {
...servers,
[PRODUCT_IDENTITY.mcpServerKey]: desired,
};
try {
await mkdir(dirname(client.configPath), { recursive: true });
await writeFileAtomic(client.configPath, stringifyToml(document), {
encoding: "utf8",
mode: 0o600,
});
const readback = objectSchema.parse(
parseToml(await readFile(client.configPath, "utf8")),
);
if (
JSON.stringify(
parseOptionalObject(readback.mcp_servers)[
PRODUCT_IDENTITY.mcpServerKey
],
) !== JSON.stringify(desired)
)
throw new Error("TOML readback mismatch");
} catch {
await restoreConfig(client.configPath, original);
return { status: "failed", reason: "readback" };
}
return {
status: "configured",
...(backupPath === undefined ? {} : { backupPath }),
};
};
/** Transactionally install or upgrade the versioned canonical REA skill. */
export const installCanonicalSkill = async (
home: string,

View File

@ -0,0 +1,199 @@
import { copyFile, lstat, readFile, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, isAbsolute, join } from "node:path";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import writeFileAtomic from "write-file-atomic";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
import { supportedClients, type SetupClient } from "./Setup.js";
/** One explicitly classified uninstall action. */
export interface UninstallItem {
readonly name: string;
readonly status: "removed" | "retained" | "skipped" | "failed";
readonly detail: string;
}
/** Structured, idempotent result of removing REA-owned state. */
export interface UninstallResult {
readonly status: "complete" | "failed";
readonly items: readonly UninstallItem[];
}
/** Filesystem boundary used by the contained uninstall workflow. */
export interface UninstallHost {
clients(): Promise<readonly SetupClient[]>;
removeClient(client: SetupClient): Promise<UninstallItem>;
removeSkill(): Promise<UninstallItem>;
purgeData(): Promise<readonly UninstallItem[]>;
}
/** Remove only REA-owned registrations and managed files, optionally including local state. */
export const runUninstall = async (
purgeData: boolean,
host: UninstallHost = systemUninstallHost(),
): Promise<UninstallResult> => {
const items: UninstallItem[] = [];
for (const client of await host.clients())
items.push(await host.removeClient(client));
items.push(await host.removeSkill());
if (purgeData) items.push(...(await host.purgeData()));
items.push({
name: "analysis_engine",
status: "retained",
detail: "Hopper is not owned by REA uninstall.",
});
return {
status: items.some(({ status }) => status === "failed")
? "failed"
: "complete",
items,
};
};
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
export const systemUninstallHost = (home = homedir()): UninstallHost => ({
clients: () => Promise.resolve(supportedClients(home)),
removeClient,
removeSkill: () =>
removeManagedPath(
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
"skill",
),
purgeData: async () => [
await removeManagedPath(join(home, ".rea/cache"), "cache"),
await removeManagedPath(join(home, ".rea/state"), "state"),
],
});
const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
if (client.format === "unsupported")
return item(
client.name,
"skipped",
"This client has no documented local MCP configuration boundary.",
);
let original: string;
try {
original = await readFile(client.configPath, "utf8");
} catch (cause: unknown) {
return isMissing(cause)
? item(client.name, "skipped", "Configuration does not exist.")
: item(client.name, "failed", "Configuration could not be read.");
}
let document: Record<string, unknown>;
let key: "mcp_servers" | "mcpServers";
let servers: Record<string, unknown>;
try {
document = objectSchema.parse(
client.format === "toml" ? parseToml(original) : JSON.parse(original),
);
key = client.format === "toml" ? "mcp_servers" : "mcpServers";
servers = optionalObject(document[key]);
} catch {
return item(
client.name,
"failed",
"Malformed configuration was preserved without mutation.",
);
}
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
if (registration === undefined)
return item(client.name, "skipped", "REA registration is absent.");
if (!isOwnedRegistration(registration))
return item(
client.name,
"retained",
"The rea-named registration is not REA-owned.",
);
const remaining = { ...servers };
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
document[key] = remaining;
const backupPath = `${client.configPath}.rea.backup`;
try {
await copyFile(client.configPath, backupPath);
} catch {
return item(
client.name,
"failed",
"Configuration backup failed; no mutation was attempted.",
);
}
try {
const encoded =
client.format === "toml"
? stringifyToml(document)
: `${JSON.stringify(document, null, 2)}\n`;
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
const readback = objectSchema.parse(
client.format === "toml"
? parseToml(await readFile(client.configPath, "utf8"))
: JSON.parse(await readFile(client.configPath, "utf8")),
);
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
throw new Error("registration readback mismatch");
return item(
client.name,
"removed",
`Removed registration from ${client.configPath}.`,
);
} catch {
await writeFileAtomic(client.configPath, original, {
encoding: "utf8",
}).catch(() => undefined);
return item(
client.name,
"failed",
`Configuration update failed; the original was restored and ${backupPath} was retained.`,
);
}
};
/** Ownership rule for persistent MCP registrations created by REA setup. */
const isOwnedRegistration = (value: unknown): boolean => {
const parsed = registrationSchema.safeParse(value);
if (!parsed.success) return false;
const { command, args } = parsed.data;
return (
((command === "rea" ||
(isAbsolute(command) && basename(command) === "rea")) &&
args.length === 1 &&
args[0] === "mcp") ||
(command === "npx" &&
JSON.stringify(args) ===
JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"]))
);
};
const removeManagedPath = async (
path: string,
name: string,
): Promise<UninstallItem> => {
try {
const stats = await lstat(path);
if (stats.isSymbolicLink())
return item(name, "retained", `Refused to follow symlink ${path}.`);
await rm(path, { recursive: true });
return item(name, "removed", `Removed ${path}.`);
} catch (cause: unknown) {
return isMissing(cause)
? item(name, "skipped", `${path} does not exist.`)
: item(name, "failed", `${path} could not be removed.`);
}
};
const item = (
name: string,
status: UninstallItem["status"],
detail: string,
): UninstallItem => ({ name, status, detail });
const objectSchema = z.record(z.string(), z.unknown());
const optionalObject = (value: unknown): Record<string, unknown> =>
value === undefined ? {} : objectSchema.parse(value);
const registrationSchema = z
.object({ command: z.string(), args: z.array(z.string()) })
.passthrough();
const isMissing = (cause: unknown): boolean =>
cause instanceof Error && "code" in cause && cause.code === "ENOENT";

View File

@ -7,6 +7,7 @@ import {
runProviderAnalysis,
} from "./application/DirectAnalysis.js";
import { runSetup } from "./application/Setup.js";
import { runUninstall } from "./application/Uninstall.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
import { parseConfig } from "./config.js";
@ -29,7 +30,7 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
: parseLogLevel(process.env.REA_LOG_LEVEL),
);
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
version: "0.1.0",
version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
description:
"Reverse engineer anything from your terminal or coding agent.",
mcp: {
@ -68,6 +69,18 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
run: ({ options }) =>
logCliCommand(logger, "doctor", () => runDoctor(options.target)),
});
cli.command("uninstall", {
description: "Remove REA-owned agent configuration and skill files",
options: z.object({
purgeData: z
.boolean()
.default(false)
.describe("Also remove REA caches and state"),
}),
alias: { purgeData: "purge-data" },
run: ({ options }) =>
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
});
cli.command("analyze", {
description: "Get an overview of an app",
args: z.object({

View File

@ -9,6 +9,10 @@ import type { ReferenceSourcePolicy } from "./domain/referenceSourcePolicy.js";
const DEFAULT_HOPPER_LAUNCHER_PATH =
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
const defaultHopperLauncherPath = (): string =>
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: DEFAULT_HOPPER_LAUNCHER_PATH;
export interface AppConfig {
readonly hopperLauncherPath: string;
@ -134,7 +138,7 @@ export const parseConfig = (
return ok({
hopperLauncherPath:
parsedEnvironment.data.HOPPER_LAUNCHER_PATH ??
DEFAULT_HOPPER_LAUNCHER_PATH,
defaultHopperLauncherPath(),
hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH,
hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND,
hopperLoaderArgs: parsedArgs.data,

View File

@ -10,7 +10,11 @@ describe("runtime configuration", () => {
const result = parseConfig({ HOPPER_TARGET_PATH: "/usr/bin/true" });
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.hopperLauncherPath).toContain("/MacOS/hopper");
expect(result.value.hopperLauncherPath).toBe(
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
expect(result.value.hopperTargetKind).toBe("executable");
expect(result.value.hopperLoaderArgs).toEqual([]);
expect(result.value.logLevel).toBe("info");
@ -36,7 +40,9 @@ describe("runtime configuration", () => {
ok: true,
value: {
hopperLauncherPath:
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
hopperTargetPath: "/fixture/sample.hop",
hopperTargetKind: "database",
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],

View File

@ -3,8 +3,9 @@ import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
platform: "darwin",
nodeVersion: "22.1.0",
nodeVersion: "24.18.0",
macosVersion: () => Promise.resolve("12.0"),
linuxDistribution: () => Promise.resolve(undefined),
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
executable: (path) => Promise.resolve(path.includes("Hopper")),
brewHopperPath: () => Promise.resolve(undefined),
@ -20,9 +21,7 @@ describe("doctor", () => {
undefined,
host({ macosVersion: () => Promise.resolve(version) }),
);
expect(result.checks.find(({ name }) => name === "macos")?.ok).toBe(
false,
);
expect(result.checks.find(({ name }) => name === "host")?.ok).toBe(false);
},
);
it("detects a manual configured Hopper before Homebrew", async () => {
@ -36,6 +35,27 @@ describe("doctor", () => {
expect(result.hopperPath).toBe("/manual/Hopper");
expect(result.healthy).toBe(true);
});
it("accepts an officially supported Linux distribution", async () => {
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
const result = await runDoctor(
undefined,
host({
platform: "linux",
macosVersion: () => Promise.resolve(undefined),
linuxDistribution: () =>
Promise.resolve({
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
}),
configuredHopperPath: path,
executable: (candidate) => Promise.resolve(candidate === path),
}),
);
expect(result.healthy).toBe(true);
expect(result.hopperPath).toBe(path);
});
it("detects a Homebrew cask installed outside /Applications", async () => {
const path =
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";

199
tests/installScript.test.ts Normal file
View File

@ -0,0 +1,199 @@
import { execFile } from "node:child_process";
import {
chmod,
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("curl installer scenarios", () => {
it("installs a pinned release with closed stdin and cleans temporary files", 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");
expect(await readFile(fixture.npmLog, "utf8")).toContain(
"rea-agents@0.3.0",
);
expect(await readdir(fixture.temporary)).toEqual([]);
});
it("resolves and validates the latest rea-agents release tag", async () => {
const fixture = await createFixture();
const result = await runInstaller(fixture);
expect(result.stdout).toContain("Installing rea-agents@0.3.0");
});
it("bootstraps checksum-verified Node 24 into the user prefix", 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,
});
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",
],
] 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([]);
});
});
interface InstallerFixture {
readonly home: string;
readonly bin: string;
readonly temporary: string;
readonly npmLog: string;
readonly nodeArchive: string;
readonly nodeSums: string;
}
const createFixture = async (): Promise<InstallerFixture> => {
const root = await mkdtemp(join(tmpdir(), "rea-install-test-"));
roots.push(root);
const home = join(root, "home");
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");
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`,
);
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',
);
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',
);
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',
);
return { home, bin, temporary, npmLog, nodeArchive, nodeSums };
};
const executable = async (path: string, source: string): Promise<void> => {
await writeFile(path, source);
await chmod(path, 0o755);
};
const runInstaller = async (
fixture: InstallerFixture,
overrides: Readonly<Record<string, string>> = {},
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
execFileAsync("/bin/bash", [join(process.cwd(), "install.sh")], {
env: {
...process.env,
HOME: fixture.home,
TMPDIR: fixture.temporary,
PATH: `${fixture.bin}:${process.env.PATH ?? ""}`,
FAKE_NPM_LOG: fixture.npmLog,
...overrides,
},
});
const shellQuote = (value: string): string =>
`'${value.replaceAll("'", `'"'"'`)}'`;

View File

@ -8,7 +8,9 @@ describe("configuration parsing integration", () => {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("unreachable");
expect(result.value.hopperLauncherPath).toBe(
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
process.platform === "linux"
? "/opt/hopper/bin/Hopper"
: "/Applications/Hopper Disassembler.app/Contents/MacOS/hopper",
);
expect(result.value.hopperTargetPath).toBeUndefined();
expect(result.value.hopperTargetKind).toBe("executable");

214
tests/linuxHopper.test.ts Normal file
View File

@ -0,0 +1,214 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
installLinuxHopper,
linuxPackageManagerCommand,
linuxSharedLibrariesAvailable,
parseLinuxDistribution,
type LinuxDistribution,
type LinuxHopperDownload,
type LinuxHopperInstallHost,
type LinuxPackageFamily,
} from "../src/application/LinuxHopper.js";
class RecordingLinuxHost implements LinuxHopperInstallHost {
distributionValue: LinuxDistribution | undefined = {
id: "ubuntu",
versionId: "24.04",
packageFamily: "deb",
supported: true,
};
metadata: unknown = releasesFor(new Uint8Array([1, 2, 3]));
archive = new Uint8Array([1, 2, 3]);
metadataOk = true;
archiveOk = true;
installSucceeds = true;
launcherExists = true;
downloads: string[] = [];
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
cleaned: string[] = [];
distribution = (): Promise<LinuxDistribution | undefined> =>
Promise.resolve(this.distributionValue);
download = (url: string): Promise<LinuxHopperDownload> => {
this.downloads.push(url);
if (url.includes("files-api.php"))
return Promise.resolve({
ok: this.metadataOk,
bytes: new TextEncoder().encode(JSON.stringify(this.metadata)),
});
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
};
createTemporaryDirectory = (): Promise<string> =>
Promise.resolve("/tmp/rea test");
writeArchive = (): Promise<void> => Promise.resolve();
installPackage = (
family: LinuxPackageFamily,
archive: string,
): Promise<boolean> => {
this.installed.push({ family, archive });
return Promise.resolve(this.installSucceeds);
};
launcherReady = (): Promise<boolean> => Promise.resolve(this.launcherExists);
cleanup = (path: string): Promise<void> => {
this.cleaned.push(path);
return Promise.resolve();
};
}
describe("Linux Hopper host classification", () => {
it.each([
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
["ID=fedora\nVERSION_ID=41\n", "rpm"],
["ID=arch\n", "arch"],
] as const)("accepts an official Hopper distribution", (document, family) => {
expect(parseLinuxDistribution(document)).toMatchObject({
packageFamily: family,
supported: true,
});
});
it("detects unresolved or loader-reported shared libraries", () => {
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => not found\nlibc.so.6 => /lib/libc.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"error while loading shared libraries: libQt6Gui.so.6",
),
).toBe(false);
expect(
linuxSharedLibrariesAvailable(
"libQt6Network.so.6 => /usr/lib/libQt6Network.so.6",
),
).toBe(true);
});
it.each([
'ID=ubuntu\nVERSION_ID="22.04"\n',
"ID=fedora\nVERSION_ID=40\n",
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
])("rejects unsupported vendor/version combinations", (document) => {
expect(parseLinuxDistribution(document).supported).toBe(false);
});
});
describe("Linux Hopper installation", () => {
it.each([
["deb", "apt-get"],
["rpm", "dnf"],
["arch", "pacman"],
] as const)("selects the %s native package manager", (family, executable) => {
expect(
linuxPackageManagerCommand(family, "/tmp/Hopper package", true),
).toMatchObject({
executable,
});
expect(
linuxPackageManagerCommand(family, "/tmp/Hopper package", false),
).toMatchObject({
executable: "pkexec",
args: [
executable,
expect.any(String),
expect.any(String),
expect.any(String),
],
});
});
it.each(["deb", "rpm", "arch"] as const)(
"downloads, verifies, installs, reads back, and cleans a %s package",
async (family) => {
const host = new RecordingLinuxHost();
host.distributionValue = {
id: family,
packageFamily: family,
supported: true,
};
const result = await installLinuxHopper(host);
expect(result).toEqual({
status: "installed",
launcherPath: "/opt/hopper/bin/Hopper",
});
expect(host.installed).toEqual([
{ family, archive: `/tmp/rea test/hopper.${family}` },
]);
expect(host.cleaned).toEqual(["/tmp/rea test"]);
},
);
it.each([
["unsupported host", "unsupported_host"],
["malformed metadata", "release_metadata"],
["unexpected origin", "release_metadata"],
["metadata request failure", "release_metadata"],
["package request failure", "download"],
["checksum mismatch", "integrity"],
["package manager failure", "authorization_or_package_manager"],
["missing launcher", "launcher_missing"],
] as const)("classifies %s", async (scenario, reason) => {
const host = new RecordingLinuxHost();
if (scenario === "unsupported host")
host.distributionValue = {
id: "debian",
packageFamily: "deb",
supported: false,
};
if (scenario === "malformed metadata") host.metadata = { invalid: true };
if (scenario === "unexpected origin") {
const metadata = releasesFor(host.archive);
const ubuntu = metadata["Ubuntu / Mint"];
if (ubuntu === undefined)
throw new Error("fixture omitted Ubuntu release");
ubuntu.filename = "https://example.com/Hopper.deb";
host.metadata = metadata;
}
if (scenario === "metadata request failure") host.metadataOk = false;
if (scenario === "package request failure") host.archiveOk = false;
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
if (scenario === "package manager failure") host.installSucceeds = false;
if (scenario === "missing launcher") host.launcherExists = false;
expect(await installLinuxHopper(host)).toEqual({
status: "failed",
reason,
});
if (host.installed.length > 0)
expect(host.cleaned).toEqual(["/tmp/rea test"]);
});
it("cancels before network or mutation", async () => {
const host = new RecordingLinuxHost();
const controller = new AbortController();
controller.abort();
expect(
await installLinuxHopper(host, { signal: controller.signal }),
).toEqual({
status: "failed",
reason: "cancelled",
});
expect(host.downloads).toEqual([]);
expect(host.installed).toEqual([]);
});
});
function releasesFor(
bytes: Uint8Array,
): Record<
string,
{ filename: string; file_length: string; file_hash: string }
> {
const release = (extension: string) => ({
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
file_length: String(bytes.byteLength),
file_hash: createHash("sha1").update(bytes).digest("hex"),
});
return {
"Ubuntu / Mint": release("deb"),
Fedora: release("rpm"),
Arch: release("pkg.tar.xz"),
};
}

View File

@ -7,10 +7,11 @@ import {
type SetupHost,
} 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 = "darwin" as const;
readonly nodeVersion = "22.4.0";
readonly platform: NodeJS.Platform;
readonly nodeVersion = "24.18.0";
version: string | undefined = "14.5";
brew = false;
hopper: string | undefined;
@ -23,9 +24,16 @@ class FakeSetupHost implements SetupHost {
hopperInstalls = 0;
configurations = 0;
configuredHopperPaths: string[] = [];
distribution: LinuxDistribution | undefined;
constructor(platform: NodeJS.Platform = "darwin") {
this.platform = platform;
}
macosVersion = (): Promise<string | undefined> =>
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;
@ -61,7 +69,8 @@ class FakeSetupHost implements SetupHost {
checks: readonly DoctorCheck[];
}> =>
Promise.resolve({
healthy: this.brew && this.hopper !== undefined,
healthy:
(this.platform === "linux" || this.brew) && this.hopper !== undefined,
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
checks: [],
});
@ -71,7 +80,7 @@ describe("setup workflow", () => {
it("installs Homebrew and Hopper on a clean approved machine", async () => {
const host = new FakeSetupHost();
const result = await runSetup(true, host);
expect(result.status).toBe("ready");
expect(result.status).toBe("needs_human");
expect(result.actions).toEqual([
"installed_homebrew",
"installed_hopper",
@ -79,6 +88,34 @@ describe("setup workflow", () => {
]);
});
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);
expect(result.status).toBe("needs_human");
expect(result.actions).toEqual(["installed_hopper", "installed_skill"]);
expect(host.homebrewInstalls).toBe(0);
});
it("rejects unsupported Linux before mutation", async () => {
const host = new FakeSetupHost("linux");
host.distribution = {
id: "debian",
versionId: "13",
packageFamily: "deb",
supported: false,
};
const result = await runSetup(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);

View File

@ -0,0 +1,131 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
configureTomlClient,
detectClients,
} from "../src/application/Setup.js";
import {
runUninstall,
systemUninstallHost,
} from "../src/application/Uninstall.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
describe("agent lifecycle", () => {
it("detects every supported client and skips absent clients", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-detect-"));
roots.push(home);
for (const marker of [
".claude",
"Library/Application Support/Claude",
".codex",
".cursor",
".gemini",
".codeium/windsurf",
".devin",
])
await mkdir(join(home, marker), { recursive: true });
const detected = await detectClients(home);
expect(detected.map(({ name }) => name)).toEqual([
"claude_code",
"claude_desktop",
"codex",
"cursor",
"gemini_cli",
"windsurf",
"devin",
]);
expect(
detected.find(({ name }) => name === "claude_code")?.configPath,
).toBe(join(home, ".claude.json"));
expect(detected.find(({ name }) => name === "devin")?.format).toBe(
"unsupported",
);
const emptyHome = await mkdtemp(join(tmpdir(), "rea-empty-"));
roots.push(emptyHome);
expect(await detectClients(emptyHome)).toEqual([]);
});
it("preserves unrelated Codex TOML and supports an installed command", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-toml-"));
roots.push(home);
const configPath = join(home, ".codex/config.toml");
await mkdir(dirname(configPath), { recursive: true });
await writeFile(
configPath,
'model = "gpt-5"\n[mcp_servers.other]\ncommand = "other"\n',
);
expect(
await configureTomlClient(
{ name: "codex", configPath, format: "toml" },
"/Hopper Path",
["rea", "mcp"],
),
).toMatchObject({ status: "configured" });
const configured = await readFile(configPath, "utf8");
expect(configured).toContain('model = "gpt-5"');
expect(configured).toContain("[mcp_servers.rea]");
expect(configured).toContain('command = "rea"');
expect(
await configureTomlClient(
{ name: "codex", configPath, format: "toml" },
"/Hopper Path",
["rea", "mcp"],
),
).toEqual({ status: "unchanged" });
});
it("uninstalls only owned entries and refuses purge symlinks", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-"));
roots.push(home);
const cursor = join(home, ".cursor/mcp.json");
const skill = join(home, ".agents/skills/rea-analysis/SKILL.md");
await mkdir(dirname(cursor), { recursive: true });
await mkdir(dirname(skill), { recursive: true });
await writeFile(
cursor,
JSON.stringify({
mcpServers: {
rea: { command: "/isolated prefix/bin/rea", args: ["mcp"] },
other: { command: "other" },
},
}),
);
await writeFile(skill, "managed");
await mkdir(join(home, ".rea"), { recursive: true });
await import("node:fs/promises").then(({ symlink }) =>
symlink(home, join(home, ".rea/cache")),
);
const first = await runUninstall(true, systemUninstallHost(home));
expect(first.status).toBe("complete");
expect(JSON.parse(await readFile(cursor, "utf8"))).toEqual({
mcpServers: { other: { command: "other" } },
});
expect(first.items).toContainEqual(
expect.objectContaining({ name: "cache", status: "retained" }),
);
expect((await runUninstall(true, systemUninstallHost(home))).status).toBe(
"complete",
);
});
it("fails closed on malformed client configuration", async () => {
const home = await mkdtemp(join(tmpdir(), "rea-uninstall-bad-"));
roots.push(home);
const config = join(home, ".cursor/mcp.json");
await mkdir(dirname(config), { recursive: true });
await writeFile(config, "not-json");
expect((await runUninstall(false, systemUninstallHost(home))).status).toBe(
"failed",
);
expect(await readFile(config, "utf8")).toBe("not-json");
});
});