From e651cb65db87b6d9df9fb13b66a6e5e76f0965e0 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:42:20 +0000 Subject: [PATCH 1/3] feat: add cross-platform installation lifecycle --- install.sh | 198 ++++++++++++++ package-lock.json | 2 +- package.json | 3 + scripts/rea.mjs | 4 + scripts/verify-package.mjs | 75 +++--- scripts/verify-real-hopper-linux.mjs | 22 ++ src/application/Doctor.ts | 83 ++++-- src/application/LinuxHopper.ts | 273 ++++++++++++++++++++ src/application/Setup.ts | 229 +++++++++++++--- src/application/Uninstall.ts | 199 ++++++++++++++ src/cli.ts | 15 +- src/config.ts | 6 +- tests/config.test.ts | 10 +- tests/doctor.test.ts | 28 +- tests/installScript.test.ts | 199 ++++++++++++++ tests/integration/configIntegration.test.ts | 4 +- tests/linuxHopper.test.ts | 214 +++++++++++++++ tests/setup.test.ts | 45 +++- tests/setupLifecycle.test.ts | 131 ++++++++++ 19 files changed, 1630 insertions(+), 110 deletions(-) create mode 100755 install.sh create mode 100644 scripts/verify-real-hopper-linux.mjs create mode 100644 src/application/LinuxHopper.ts create mode 100644 src/application/Uninstall.ts create mode 100644 tests/installScript.test.ts create mode 100644 tests/linuxHopper.test.ts create mode 100644 tests/setupLifecycle.test.ts diff --git a/install.sh b/install.sh new file mode 100755 index 000000000..e72608dd0 --- /dev/null +++ b/install.sh @@ -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 diff --git a/package-lock.json b/package-lock.json index 42c75a8f3..85fa3b80b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 97c2fe0a1..67b79379e 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/rea.mjs b/scripts/rea.mjs index d89bd084c..ee5af994b 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -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"); diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index e1fa3f0b5..a61a2600c 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -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 }); diff --git a/scripts/verify-real-hopper-linux.mjs b/scripts/verify-real-hopper-linux.mjs new file mode 100644 index 000000000..15f48fb52 --- /dev/null +++ b/scripts/verify-real-hopper-linux.mjs @@ -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"); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 061cc0492..7eff0f43a 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -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; + linuxDistribution(): Promise; validTarget(path: string): Promise; executable(path: string): Promise; brewHopperPath(): Promise; @@ -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 => ({ name, ok, + classification: ok ? "healthy" : failure.classification, ...(detail === undefined ? {} : { detail }), - ...(ok ? {} : { remediation }), + ...(ok ? {} : { remediation: failure.remediation }), }); diff --git a/src/application/LinuxHopper.ts b/src/application/LinuxHopper.ts new file mode 100644 index 000000000..88085ef4d --- /dev/null +++ b/src/application/LinuxHopper.ts @@ -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; + download( + url: string, + options: { readonly signal?: AbortSignal }, + ): Promise; + createTemporaryDirectory(): Promise; + writeArchive(path: string, bytes: Uint8Array): Promise; + installPackage(family: LinuxPackageFamily, archive: string): Promise; + launcherReady(path: string): Promise; + cleanup(path: string): Promise; +} + +/** 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(); + 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 => { + 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 => { + 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 | 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, +): 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, +}); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index a17fc200f..a408aee59 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -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; + linuxDistribution(): Promise; hasHomebrew(): Promise; installHomebrew(): Promise; hopperPath(): Promise; @@ -52,6 +66,7 @@ export interface SetupHost { configureClient( client: SetupClient, hopperPath: string, + command: readonly string[], ): Promise; installSkill(): Promise<"installed" | "unchanged" | "failed">; doctor(): Promise>>; @@ -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 => { + 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 => { - 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 => { let document: Record = {}; 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 => { + let document: Record = {}; + 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; + 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, diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts new file mode 100644 index 000000000..fbc650d08 --- /dev/null +++ b/src/application/Uninstall.ts @@ -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; + removeClient(client: SetupClient): Promise; + removeSkill(): Promise; + purgeData(): Promise; +} + +/** Remove only REA-owned registrations and managed files, optionally including local state. */ +export const runUninstall = async ( + purgeData: boolean, + host: UninstallHost = systemUninstallHost(), +): Promise => { + 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 => { + 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; + let key: "mcp_servers" | "mcpServers"; + let servers: Record; + 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 => { + 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 => + 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"; diff --git a/src/cli.ts b/src/cli.ts index 05bc1f20b..143a6083f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 => { : 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 => { 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({ diff --git a/src/config.ts b/src/config.ts index 2d13d61bc..62e952170 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, diff --git a/tests/config.test.ts b/tests/config.test.ts index eb62bea69..ab86eadce 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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"], diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index fc298ad94..ad6503cda 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -3,8 +3,9 @@ import { runDoctor, type DoctorHost } from "../src/application/Doctor.js"; const host = (overrides: Partial = {}): 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"; diff --git a/tests/installScript.test.ts b/tests/installScript.test.ts new file mode 100644 index 000000000..a1a918e00 --- /dev/null +++ b/tests/installScript.test.ts @@ -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 => { + 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 => { + await writeFile(path, source); + await chmod(path, 0o755); +}; + +const runInstaller = async ( + fixture: InstallerFixture, + overrides: Readonly> = {}, +): 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("'", `'"'"'`)}'`; diff --git a/tests/integration/configIntegration.test.ts b/tests/integration/configIntegration.test.ts index 70e4efa79..948d034fa 100644 --- a/tests/integration/configIntegration.test.ts +++ b/tests/integration/configIntegration.test.ts @@ -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"); diff --git a/tests/linuxHopper.test.ts b/tests/linuxHopper.test.ts new file mode 100644 index 000000000..5dad92bc8 --- /dev/null +++ b/tests/linuxHopper.test.ts @@ -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 => + Promise.resolve(this.distributionValue); + download = (url: string): Promise => { + 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 => + Promise.resolve("/tmp/rea test"); + writeArchive = (): Promise => Promise.resolve(); + installPackage = ( + family: LinuxPackageFamily, + archive: string, + ): Promise => { + this.installed.push({ family, archive }); + return Promise.resolve(this.installSucceeds); + }; + launcherReady = (): Promise => Promise.resolve(this.launcherExists); + cleanup = (path: string): Promise => { + 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"), + }; +} diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 04cfb9f74..670b64e6a 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -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 => Promise.resolve(this.version); + linuxDistribution = (): Promise => + Promise.resolve(this.distribution); hasHomebrew = (): Promise => Promise.resolve(this.brew); installHomebrew = (): Promise => { 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); diff --git a/tests/setupLifecycle.test.ts b/tests/setupLifecycle.test.ts new file mode 100644 index 000000000..e08381d66 --- /dev/null +++ b/tests/setupLifecycle.test.ts @@ -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"); + }); +}); From 4f97f699e4d5471557d1ce0ff91e144065d06a61 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:42:34 +0000 Subject: [PATCH 2/3] ci: verify installed package with Linux Hopper --- .github/pull_request_template.md | 1 + .github/workflows/real-hopper-linux.yml | 43 +++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 .github/workflows/real-hopper-linux.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4e1fe769d..e745d42c2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,7 @@ - [ ] Linting passes (`npm run lint`) - [ ] Full test suite passes (`npm test`) - [ ] Real Hopper verification performed (`npm run verify:hopper`) +- [ ] Linux real Hopper verification performed (`npm run verify:hopper:linux`) or unavailability explained - [ ] Package verification performed (`npm run verify:package`) ## Tool Contract Impact diff --git a/.github/workflows/real-hopper-linux.yml b/.github/workflows/real-hopper-linux.yml new file mode 100644 index 000000000..7ff3d5278 --- /dev/null +++ b/.github/workflows/real-hopper-linux.yml @@ -0,0 +1,43 @@ +name: Real Hopper Linux E2E + +on: + workflow_dispatch: + inputs: + first_target: + description: Absolute path to first Linux test binary + required: true + type: string + second_target: + description: Absolute path to a distinct Linux test binary + required: true + type: string + +permissions: + contents: read + +jobs: + verify: + name: Installed package against real Linux Hopper + runs-on: [self-hosted, Linux, hopper] + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: npm + - run: npm ci + - run: npm run rebuild:native + - name: Install packed CLI in isolation + shell: bash + run: | + tarball="$(npm pack --silent)" + prefix="${RUNNER_TEMP}/rea-prefix" + npm install --global --prefix "${prefix}" "./${tarball}" + echo "REA_VERIFY_SERVER_COMMAND=${prefix}/bin/rea" >> "${GITHUB_ENV}" + echo 'REA_VERIFY_SERVER_ARGS_JSON=["mcp"]' >> "${GITHUB_ENV}" + - name: Verify two binaries through the in-process bridge + env: + HOPPER_TARGET_PATH: ${{ inputs.first_target }} + HOPPER_SECOND_TARGET_PATH: ${{ inputs.second_target }} + run: npm run verify:hopper:linux From 034563a4c015c69e878054d4fd06e0e9fa630e24 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:42:46 +0000 Subject: [PATCH 3/3] docs: document installation and Linux support --- CONTRIBUTING.md | 18 ++++++++++--- README.md | 47 +++++++++++++++++++++++++++++++-- README_ar.md | 12 ++++++++- README_ja.md | 12 ++++++++- README_ko.md | 12 ++++++++- README_zh.md | 12 ++++++++- SECURITY.md | 6 +++-- docs/architecture.mermaid | 6 +++-- tests/readmeConsistency.test.ts | 4 +++ 9 files changed, 115 insertions(+), 14 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8f3466764..8816212b4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ REA welcomes focused bug fixes, documentation improvements, tests, and reverse-e ## Development setup -REA requires macOS 12 or newer, Node.js 24.18.x, npm 11.16.x, and a separately installed Hopper Disassembler application for real-Hopper verification. Run `nvm use` before installing dependencies. +REA development requires Node.js 24.18.x and npm 11.16.x. Real-Hopper verification additionally requires either macOS 12+ or an officially supported Linux host (Ubuntu 24.04+, Fedora 41+, or 64-bit Arch), an installed and activated Hopper application, and a desktop session. Run `nvm use` before installing dependencies. ```bash npm ci @@ -13,7 +13,7 @@ npm run build npm test ``` -Keep dependencies flowing inward through the existing domain, Hopper, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 31 direct, 8 enhanced, and 3 session tools unless a deliberate contract change updates every verifier and snapshot. +Keep dependencies flowing inward through the existing domain, contracts, Hopper, application, server, and adapter layers. Parse unknown values at process and protocol boundaries, model expected failures with `Result`, and preserve the 33 direct, 10 enhanced, 5 native, 2 artifact, and 18 session tools (68 total) unless a deliberate contract change updates every verifier and snapshot. Before submitting a pull request, run: @@ -43,6 +43,16 @@ HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \ npm run verify:hopper ``` +On a self-hosted Linux runner with `DISPLAY` or `WAYLAND_DISPLAY`, use: + +```bash +HOPPER_TARGET_PATH=/path/to/target-a \ +HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \ +npm run verify:hopper:linux +``` + +The macOS and Linux real-Hopper workflows remain separate so a successful mock or package test cannot be reported as platform-runtime proof. Pull requests changing setup, launch, bridge, or Hopper behavior must state which real workflows ran and why either workflow was unavailable. + Describe the behavior change and verification performed in the pull request. Never commit binaries, Hopper documents, credentials, `dist/`, `node_modules/`, or local planning artifacts. ## Maintainer release checklist @@ -54,13 +64,13 @@ npm pack npm exec --yes --package ./morluto-rea-0.1.0.tgz -- rea --help ``` -Publish the public scoped package: +Publish the public package: ```bash npm publish --access public ``` -After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 42 tools: +After npm registry propagation, verify the published CLI and connect a beta.3 MCP client to the published server to confirm all 68 tools: ```bash npx -y rea-agents --help diff --git a/README.md b/README.md index b57d7e004..89607b6b7 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@
-npx skills add morluto/rea +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash @@ -81,6 +81,16 @@ REA shows how it reached its conclusions. It does not claim to recover original ## Quick start +### One-command install — recommended + +```bash +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash +``` + +The installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. It installs compatible Node.js 24/npm 11 prerequisites, the exact latest `rea-agents-*` release, the official Hopper package, detected client registrations, and the bundled skill, then verifies the result. macOS uses Homebrew; Linux uses the native package manager and may show a system authorization prompt. Pin a reproducible release with `REA_VERSION=0.3.0`; `REA_VERSION` is the only supported installer environment override. + +REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It configures detected clients with documented local MCP files; Devin is reported as skipped because its public documentation does not define a local configuration file. You can safely rerun the installer or `rea setup --yes`. If REA's local analysis engine needs one-time activation, installation remains intact and setup reports the exact remaining action rather than claiming full readiness. + ### With a coding agent — recommended ```bash @@ -115,9 +125,10 @@ Choose either the no-install commands or the global installation. You do not nee ### Requirements - macOS 12 or newer +- Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux - Node.js 24.18.x with npm 11.16.x -You do not need to choose or install binary-analysis tools yourself. REA setup handles them when needed. Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate Mac app with its own license. REA can install it, but you must approve the installation and complete its one-time activation. +You do not need to choose or install binary-analysis tools yourself. REA setup handles them when needed. Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. REA can install the official macOS or Linux package, but you must approve installation and complete its one-time activation. If something is not working, run: @@ -125,6 +136,38 @@ If something is not working, run: npx -y rea-agents doctor ``` +`rea doctor --json` is read-only and distinguishes unsupported hosts, missing dependencies, a missing local analysis engine, configuration drift, and healthy checks. Fresh-install activation is reported by setup because Hopper does not expose a reliable noninteractive activation probe. + +### Linux installation and troubleshooting + +On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`. + +The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere: + +```bash +export HOPPER_LAUNCHER_PATH=/absolute/path/to/Hopper +rea doctor --json +``` + +If doctor reports a missing analysis engine even though the file exists, inspect shared-library resolution with: + +```bash +ldd /opt/hopper/bin/Hopper | grep 'not found' +``` + +Install the missing distribution packages and rerun `rea setup --yes`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present. + +REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence. + +To remove only REA-owned MCP registrations and the managed skill: + +```bash +rea uninstall +rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state +``` + +Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks. + ### CLI or coding agent? | If you want to… | Use | diff --git a/README_ar.md b/README_ar.md index 836f85cd0..e318a9041 100644 --- a/README_ar.md +++ b/README_ar.md @@ -18,7 +18,7 @@
-npx skills add morluto/rea +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash @@ -73,13 +73,21 @@ npx skills add morluto/rea ### قبل البدء - macOS 12 أو أحدث +- Ubuntu 24.04+ أو Fedora 41+ أو Arch Linux ‏64 بت - Node.js 24.18.x وnpm 11.16.x (شغّل `nvm use` لاختيار الإصدار المثبّت) لا تحتاج إلى تثبيت أدوات الهندسة العكسية يدويًا. يثبت Setup برنامج Homebrew و[Hopper](https://www.hopperapp.com/) عند الحاجة، ثم يهيئ وكلاء البرمجة المدعومين. Hopper برنامج منفصل يحتاج إلى ترخيص خاص به؛ يثبته Setup لكنه لا يوفر الترخيص. +#### التثبيت على Linux واستكشاف الأخطاء + +على Ubuntu 24.04+ وFedora 41+ وArch Linux ‏64 بت، ينزّل REA حزمة DEB أو RPM أو Arch الرسمية، ويتحقق من الحجم وقيمة التحقق المنشورين، ثم يستخدم `apt-get` أو `dnf` أو `pacman` لحل الاعتماديات. عند التشغيل دون root يعرض `pkexec` طلب تفويض النظام. لا يستدعي REA الأمر `sudo`. + +مسار المشغّل الافتراضي هو `/opt/hopper/bin/Hopper`. استخدم `HOPPER_LAUNCHER_PATH` للمسارات الأخرى. إذا أبلغ Doctor عن غياب محرك التحليل، شغّل `ldd /opt/hopper/bin/Hopper | grep 'not found'`، وثبّت المكتبات الناقصة ثم أعد `rea setup --yes`. يتطلب التحليل الحقيقي `DISPLAY` أو `WAYLAND_DISPLAY` وترخيص Hopper مفعّلًا. أضف `~/.local/bin` إلى `PATH` أيضًا. + ### 1. إعداد REA ```bash +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash npx -y rea-agents setup --yes ``` @@ -102,6 +110,8 @@ npx -y rea-agents setup --yes ```bash npx -y rea-agents doctor +rea uninstall +rea uninstall --purge-data ``` ## استقصاء كامل بطلب واحد diff --git a/README_ja.md b/README_ja.md index e90ff7899..7015ace7d 100644 --- a/README_ja.md +++ b/README_ja.md @@ -18,7 +18,7 @@
-npx skills add morluto/rea +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash @@ -75,12 +75,20 @@ npx skills add morluto/rea ### 始める前に - macOS 12 以降 +- Ubuntu 24.04+、Fedora 41+、または 64 ビット Arch Linux - Node.js 24.18.x と npm 11.16.x(`nvm use` で固定バージョンを選択) リバースエンジニアリングツールを手動でインストールする必要はありません。Setup は必要に応じて Homebrew と [Hopper](https://www.hopperapp.com/) をインストールし、対応するコーディングエージェントを設定します。Hopper は別製品で、ライセンスは別途必要です。Setup はインストールしますが、ライセンスは提供しません。 +#### Linux のインストールとトラブルシューティング + +Ubuntu 24.04+、Fedora 41+、64 ビット Arch Linux では、REA が公式の DEB、RPM、または Arch パッケージを取得し、公開されたサイズとチェックサムを検証してから `apt-get`、`dnf`、または `pacman` で依存関係を解決します。root 以外では `pkexec` がシステム承認を表示します。REA は `sudo` を呼び出しません。 + +既定のランチャーは `/opt/hopper/bin/Hopper` です。別の場所では `HOPPER_LAUNCHER_PATH` を設定してください。Doctor が解析エンジン不足を報告した場合は `ldd /opt/hopper/bin/Hopper | grep 'not found'` を実行し、不足ライブラリを入れて `rea setup --yes` を再実行します。実解析には `DISPLAY` または `WAYLAND_DISPLAY` と有効化済み Hopper ライセンスが必要です。`~/.local/bin` も `PATH` に追加してください。 + ```bash # 1. REA をセットアップ +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash npx -y rea-agents setup --yes ``` @@ -103,6 +111,8 @@ REA でメモアプリをリバースエンジニアリングしてください ```bash npx -y rea-agents doctor +rea uninstall +rea uninstall --purge-data ``` ## ひとつのプロンプトで調査を完結 diff --git a/README_ko.md b/README_ko.md index 50f419c1d..a2e29fd55 100644 --- a/README_ko.md +++ b/README_ko.md @@ -18,7 +18,7 @@
-npx skills add morluto/rea +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash @@ -75,12 +75,20 @@ npx skills add morluto/rea ### 시작하기 전에 - macOS 12 이상 +- Ubuntu 24.04+, Fedora 41+ 또는 64비트 Arch Linux - Node.js 24.18.x 및 npm 11.16.x (`nvm use`로 고정 버전 선택) 리버스 엔지니어링 도구를 직접 설치할 필요는 없습니다. Setup은 필요할 때 Homebrew와 [Hopper](https://www.hopperapp.com/)를 설치하고 지원되는 코딩 에이전트를 구성합니다. Hopper는 별도 소프트웨어이며 별도 라이선스가 필요합니다. Setup은 Hopper를 설치하지만 라이선스를 제공하지 않습니다. +#### Linux 설치 및 문제 해결 + +Ubuntu 24.04+, Fedora 41+, 64비트 Arch Linux에서 REA는 공식 DEB, RPM 또는 Arch 패키지를 내려받아 게시된 크기와 체크섬을 검증한 뒤 `apt-get`, `dnf` 또는 `pacman`으로 의존성을 설치합니다. root가 아니면 `pkexec`가 시스템 승인 창을 표시합니다. REA는 `sudo`를 호출하지 않습니다. + +기본 실행 파일은 `/opt/hopper/bin/Hopper`입니다. 다른 위치에 설치했다면 `HOPPER_LAUNCHER_PATH`를 설정하세요. Doctor가 분석 엔진 누락을 보고하면 `ldd /opt/hopper/bin/Hopper | grep 'not found'`를 실행하고 누락된 라이브러리를 설치한 다음 `rea setup --yes`를 다시 실행하세요. 실제 분석에는 `DISPLAY` 또는 `WAYLAND_DISPLAY`와 활성화된 Hopper 라이선스가 필요합니다. `~/.local/bin`도 `PATH`에 추가해야 합니다. + ```bash # 1. REA 설정 +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash npx -y rea-agents setup --yes ``` @@ -103,6 +111,8 @@ SQLite를 사용해 제 프로젝트에 비슷한 기능을 구현해 주세요. ```bash npx -y rea-agents doctor +rea uninstall +rea uninstall --purge-data ``` ## 하나의 프롬프트로 전체 조사 diff --git a/README_zh.md b/README_zh.md index f1fc965a4..1ca391939 100644 --- a/README_zh.md +++ b/README_zh.md @@ -18,7 +18,7 @@
-npx skills add morluto/rea +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash @@ -75,12 +75,20 @@ npx skills add morluto/rea ### 开始之前 - macOS 12 或更高版本 +- Ubuntu 24.04+、Fedora 41+ 或 64 位 Arch Linux - Node.js 24.18.x 和 npm 11.16.x(运行 `nvm use` 选择固定版本) 你不需要手动安装逆向工程工具。Setup 会在需要时安装 Homebrew 和 [Hopper](https://www.hopperapp.com/),然后配置支持的编程智能体。Hopper 是独立软件,需要单独授权;Setup 可以安装它,但不提供许可证。 +#### Linux 安装与故障排除 + +在 Ubuntu 24.04+、Fedora 41+ 和 64 位 Arch Linux 上,REA 会下载 Hopper 官方的 DEB、RPM 或 Arch 软件包,验证发布方提供的大小和校验和,然后通过 `apt-get`、`dnf` 或 `pacman` 安装依赖。非 root 运行时,`pkexec` 会显示系统授权提示;REA 不调用 `sudo`。 + +默认启动器是 `/opt/hopper/bin/Hopper`。自定义安装可设置 `HOPPER_LAUNCHER_PATH`。如果 Doctor 报告缺少分析引擎,请运行 `ldd /opt/hopper/bin/Hopper | grep 'not found'`,安装缺少的系统库后重新运行 `rea setup --yes`。真实分析需要 `DISPLAY` 或 `WAYLAND_DISPLAY` 以及已激活的 Hopper 许可证。Linux 上还应确保 `~/.local/bin` 位于 `PATH` 中。 + ```bash # 1. 安装和配置 REA +curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash npx -y rea-agents setup --yes ``` @@ -103,6 +111,8 @@ Setup 会自动配置检测到的 Claude Desktop 和 Cursor。重启应用,让 ```bash npx -y rea-agents doctor +rea uninstall +rea uninstall --purge-data ``` ## 一个提示词,完成一次完整调查 diff --git a/SECURITY.md b/SECURITY.md index aba1525d9..ccdadcdf3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,10 +10,12 @@ Do not disclose vulnerabilities, proof-of-concept exploits, credentials, private Use **Report a vulnerability** in the repository's **Security** tab to submit the report privately. Do not open a public issue to request a private contact channel. -Include the affected REA version, macOS and Hopper versions, impact, reproduction conditions, and any suggested mitigation in the private report. Remove credentials, proprietary binaries, Hopper documents, socket capability tokens, and unrelated local paths from logs or examples. +Include the affected REA version, operating system/distribution, Hopper version, impact, reproduction conditions, and any suggested mitigation in the private report. Remove credentials, proprietary binaries, Hopper documents, socket capability tokens, and unrelated local paths from logs or examples. The maintainer will acknowledge the report, assess its scope, and coordinate remediation and disclosure timing with the reporter. Please allow time for Hopper-dependent behavior to be reproduced safely. ## Security boundary -REA authenticates each local bridge session with a random capability token and a current-user Unix socket. This is not a sandbox and does not protect against malicious processes already running as the same macOS user. Opening an untrusted binary delegates parsing and analysis to Hopper with that user's permissions. +REA authenticates each local bridge session with a random capability token and a current-user Unix socket. This is not a sandbox and does not protect against malicious processes already running as the same operating-system user. Opening an untrusted binary delegates parsing and analysis to Hopper with that user's permissions. + +On Linux, REA accepts release metadata only from Hopper's HTTPS endpoint, restricts package URLs to Hopper's public download origin, bounds the package size, and compares the downloaded bytes with Hopper's published checksum before requesting installation. The published SHA-1 value is a corruption check, not a modern package signature; HTTPS origin validation remains part of the trust boundary. Dependency installation is delegated without shell evaluation to `apt-get`, `dnf`, or `pacman`, directly as root or through `pkexec`. REA never invokes `sudo`. diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid index 7eb648e5c..73efdba04 100644 --- a/docs/architecture.mermaid +++ b/docs/architecture.mermaid @@ -8,10 +8,11 @@ graph TD subgraph "Adapters" SERVER["src/server/
MCP request translation
createServer, tool registration, toolResult"] BRIDGE["bridge/hopper_bridge.py
Runs inside Hopper
adapts operations to Hopper Python API"] + INSTALL["Host installation adapters
macOS: Homebrew cask
Linux: verified vendor package + apt/dnf/pacman"] end subgraph "Application Layer" - APP["src/application/
Shared CLI/MCP session composition
setup, diagnostics, 8 enhanced workflows"] + APP["src/application/
Shared CLI/MCP session composition
setup, diagnostics, lifecycle, enhanced workflows"] end subgraph "Hopper Layer" @@ -19,7 +20,7 @@ graph TD end subgraph "Contracts Layer" - CONTRACTS["src/contracts/
Caller-visible tool schemas
31 direct, 8 enhanced, 3 session tools"] + CONTRACTS["src/contracts/
Caller-visible tool schemas
68 direct, enhanced, native, artifact, and session tools"] end subgraph "Domain Layer" @@ -40,6 +41,7 @@ graph TD MAIN --> SERVER MAIN --> APP CLI --> APP + APP --> INSTALL SERVER --> CONTRACTS SERVER --> APP APP --> HOPPER diff --git a/tests/readmeConsistency.test.ts b/tests/readmeConsistency.test.ts index 5af523905..d77293835 100644 --- a/tests/readmeConsistency.test.ts +++ b/tests/readmeConsistency.test.ts @@ -25,8 +25,12 @@ describe("localized README product facts", () => { async (path) => { const content = await readFile(resolve(path), "utf8"); expect(content).toContain("npx skills add morluto/rea"); + expect(content).toContain( + "curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash", + ); expect(content).toContain("npx -y rea-agents setup --yes"); expect(content).toContain("npx -y rea-agents doctor"); + expect(content).toContain("rea uninstall"); expect(content).toContain('"args": ["-y", "rea-agents", "mcp"]'); expect(content).toContain("Node.js 24"); expect(content).toContain("macOS 12");