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] 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"); + }); +});