diff --git a/install.sh b/install.sh index e72608dd0..454f20266 100755 --- a/install.sh +++ b/install.sh @@ -3,196 +3,111 @@ set -euo pipefail PACKAGE="rea-agents" REPOSITORY="morluto/rea" -NODE_FORMULA="node@24" -NODE_VERSION="24.18.0" -temporary_directory="" -node_destination="" -node_stage="" -node_backup="" -node_version="" - -cleanup() { - if [[ -n "$temporary_directory" && -d "$temporary_directory" ]]; then - rm -rf -- "$temporary_directory" - fi - if [[ -n "$node_stage" && -e "$node_stage" ]]; then - rm -rf -- "$node_stage" - fi - if [[ -n "$node_backup" && -e "$node_backup" && ! -e "$node_destination" ]]; then - mv -- "$node_backup" "$node_destination" - fi -} -trap cleanup EXIT HUP INT TERM +version="${REA_VERSION:-}" +dry_run=false +start_setup=true +verbose=false fail() { printf 'REA installation failed: %s\n' "$1" >&2 exit 1 } -version_at_least() { - local actual="$1" required_major="$2" required_minor="$3" - local major minor - major="${actual%%.*}" - minor="${actual#*.}" - minor="${minor%%.*}" - [[ "$major" =~ ^[0-9]+$ && "$minor" =~ ^[0-9]+$ ]] || return 1 - ((major > required_major || (major == required_major && minor >= required_minor))) +usage() { + cat <<'EOF' +Usage: install.sh [--version VERSION] [--dry-run] [--no-setup] [--no-prompt] [--verbose] + +Installs only the REA CLI. Run `rea setup` separately to configure Hopper and coding agents. +EOF } -platform="$(uname -s)" -if [[ "$platform" == "Darwin" ]]; then - macos_version="$(sw_vers -productVersion 2>/dev/null)" || fail "could not determine the macOS version." - macos_major="${macos_version%%.*}" - [[ "$macos_major" =~ ^[0-9]+$ && "$macos_major" -ge 12 ]] || fail "macOS 12 or newer is required." -elif [[ "$platform" == "Linux" ]]; then - [[ "$(uname -m)" == "x86_64" || "$(uname -m)" == "aarch64" ]] || fail "64-bit x86 or ARM Linux is required." - [[ -r /etc/os-release ]] || fail "could not identify the Linux distribution." - linux_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')" - linux_version="$(sed -n 's/^VERSION_ID=//p' /etc/os-release | tr -d '"')" - case "$linux_id" in - ubuntu) [[ "${linux_version%%.*}" -ge 24 ]] || fail "Ubuntu 24.04 or newer is required." ;; - fedora) [[ "${linux_version%%.*}" -ge 41 ]] || fail "Fedora 41 or newer is required." ;; - arch) ;; - *) fail "supported Linux distributions are Ubuntu 24.04+, Fedora 41+, and Arch Linux." ;; +while (($# > 0)); do + case "$1" in + --version) + (($# >= 2)) || fail "--version requires a value." + version="$2" + shift 2 + ;; + --dry-run) + dry_run=true + shift + ;; + --no-setup|--no-prompt) + start_setup=false + shift + ;; + --verbose) + verbose=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) fail "unknown option: $1" ;; esac -else - fail "REA supports macOS and selected 64-bit Linux distributions." +done + +if [[ "$verbose" == true ]]; then + set -x fi + +platform="$(uname -s)" +[[ "$platform" == "Darwin" || "$platform" == "Linux" ]] || fail "REA supports macOS and selected 64-bit Linux distributions." command -v curl >/dev/null 2>&1 || fail "curl is required." -command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 || fail "shasum or sha256sum is required." +command -v node >/dev/null 2>&1 || fail "Node.js 22.19+ or 24.11+ is required; install it from https://nodejs.org and retry." +command -v npm >/dev/null 2>&1 || fail "npm is required; install it with Node.js and retry." -if [[ "$platform" == "Darwin" ]] && ! command -v brew >/dev/null 2>&1; then - printf 'Installing Homebrew prerequisite...\n' - NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || fail "Homebrew installation did not complete." - if [[ -x /opt/homebrew/bin/brew ]]; then - PATH="/opt/homebrew/bin:$PATH" - elif [[ -x /usr/local/bin/brew ]]; then - PATH="/usr/local/bin:$PATH" - fi -fi -if [[ "$platform" == "Darwin" ]]; then - command -v brew >/dev/null 2>&1 || fail "Homebrew is not available after installation." -fi - -node_compatible=false -if command -v node >/dev/null 2>&1; then - node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" -fi -if version_at_least "$node_version" 24 18 && [[ "$node_version" =~ ^24\. ]]; then - node_compatible=true -fi -if [[ "$node_compatible" != true ]]; then - if [[ "$platform" == "Darwin" ]]; then - brew install "$NODE_FORMULA" || fail "Node.js 24 installation failed." - node_prefix="$(brew --prefix "$NODE_FORMULA")" || fail "could not locate Node.js 24." - PATH="$node_prefix/bin:$PATH" - else - machine="$(uname -m)" - node_arch="$([[ "$machine" == "x86_64" ]] && printf 'x64' || printf 'arm64')" - node_archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz" - node_base="https://nodejs.org/dist/v${NODE_VERSION}" - temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/rea-install.XXXXXX")" || fail "could not create a temporary directory." - curl -fsSL "$node_base/$node_archive" -o "$temporary_directory/$node_archive" || fail "Node.js download failed." - curl -fsSL "$node_base/SHASUMS256.txt" -o "$temporary_directory/SHASUMS256.txt" || fail "Node.js checksum download failed." - expected_checksum="$(awk -v archive="$node_archive" '$2 == archive { print $1 }' "$temporary_directory/SHASUMS256.txt")" - [[ "$expected_checksum" =~ ^[a-f0-9]{64}$ ]] || fail "Node.js checksum metadata was malformed." - if command -v shasum >/dev/null 2>&1; then - actual_checksum="$(shasum -a 256 "$temporary_directory/$node_archive" | awk '{ print $1 }')" - else - actual_checksum="$(sha256sum "$temporary_directory/$node_archive" | awk '{ print $1 }')" - fi - [[ "$actual_checksum" == "$expected_checksum" ]] || fail "Node.js checksum verification failed." - node_destination="$HOME/.local/share/rea/node" - node_stage="$HOME/.local/share/rea/node.new.$$" - node_backup="$HOME/.local/share/rea/node.backup.$$" - [[ ! -L "$node_destination" ]] || fail "refusing to replace a symlinked Node.js installation." - mkdir -p "$HOME/.local/share/rea" - rm -rf -- "$node_stage" "$node_backup" - mkdir "$node_stage" - tar -xJf "$temporary_directory/$node_archive" --strip-components=1 -C "$node_stage" || fail "Node.js extraction failed." - if [[ -e "$node_destination" ]]; then - mv -- "$node_destination" "$node_backup" - fi - if ! mv -- "$node_stage" "$node_destination"; then - [[ ! -e "$node_backup" ]] || mv -- "$node_backup" "$node_destination" - fail "Node.js installation replacement failed." - fi - node_stage="" - rm -rf -- "$node_backup" - node_backup="" - PATH="$node_destination/bin:$PATH" - fi -fi node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" -if ! command -v node >/dev/null 2>&1 || ! version_at_least "$node_version" 24 18 || [[ ! "$node_version" =~ ^24\. ]]; then - fail "Node.js 24.18 or newer is not active on PATH." +IFS=. read -r node_major node_minor _node_patch <<<"$node_version" +[[ "$node_major" =~ ^[0-9]+$ && "$node_minor" =~ ^[0-9]+$ ]] || fail "could not determine the active Node.js version." +if ! ((node_major == 22 && node_minor >= 19 || node_major >= 24)); then + fail "Node.js $node_version is unsupported; use Node.js 22.19+ or 24.11+." fi -command -v npm >/dev/null 2>&1 || fail "npm is missing from the Node.js installation." -npm_version="$(npm --version)" -if [[ ! "$npm_version" =~ ^11\. ]] || ! version_at_least "$npm_version" 11 16; then - if [[ "$platform" == "Linux" ]]; then - npm install --global --prefix "$HOME/.local" npm@11 || fail "npm 11 installation failed." - else - npm install --global npm@11 || fail "npm 11 installation failed." - fi -fi -npm_version="$(npm --version)" -if [[ ! "$npm_version" =~ ^11\. ]] || ! version_at_least "$npm_version" 11 16; then - fail "npm 11.16 or newer is not active on PATH." -fi -if [[ -n "$temporary_directory" ]]; then - rm -rf -- "$temporary_directory" - temporary_directory="" +if ((node_major == 24 && node_minor < 11)); then + fail "Node.js $node_version is unsupported; use Node.js 24.11 or newer." fi -if [[ -n "${REA_VERSION:-}" ]]; then - version="$REA_VERSION" - [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "REA_VERSION must be an exact semantic version." +if [[ -n "$version" ]]; then + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || fail "version must be an exact semantic version." else - release_json="$(curl -fsSL -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "could not resolve the latest GitHub release." + release_json="$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -H 'Accept: application/vnd.github+json' "https://api.github.com/repos/$REPOSITORY/releases/latest")" || fail "could not resolve the latest GitHub release." tag="$(printf '%s' "$release_json" | node -e 'let s="";process.stdin.on("data",c=>s+=c).on("end",()=>{const v=JSON.parse(s).tag_name;if(typeof v!=="string")process.exit(1);process.stdout.write(v)})')" || fail "GitHub returned malformed release metadata." [[ "$tag" =~ ^rea-agents-([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]] || fail "latest release tag is not a valid rea-agents release." version="${BASH_REMATCH[1]}" fi -printf 'Installing %s@%s...\n' "$PACKAGE" "$version" +prefix_args=() if [[ "$platform" == "Linux" ]]; then - mkdir -p "$HOME/.local/bin" - PATH="$HOME/.local/bin:$PATH" - npm install --global --prefix "$HOME/.local" "$PACKAGE@$version" || fail "npm package installation failed." + prefix_args=(--prefix "$HOME/.local") + install_bin="$HOME/.local/bin/rea" else - npm install --global "$PACKAGE@$version" || fail "npm package installation failed." + npm_prefix="$(npm prefix --global)" || fail "could not determine the npm global prefix." + install_bin="$npm_prefix/bin/rea" fi -command -v rea >/dev/null 2>&1 || fail "the rea command is not available after installation." -installed_version="$(rea --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version." + +printf 'REA install plan\n' +printf ' Version: %s\n' "$version" +printf ' Runtime: Node.js %s\n' "$node_version" +printf ' Command: npm install --global %s@%s\n' "$PACKAGE" "$version" +printf ' Binary: %s\n' "$install_bin" +printf ' Setup: %s\n' "$([[ "$start_setup" == true ]] && printf 'start when a terminal is available' || printf 'skipped')" + +if [[ "$dry_run" == true ]]; then + printf 'Dry run complete; no changes made.\n' + exit 0 +fi + +printf 'Installing %s@%s...\n' "$PACKAGE" "$version" +npm install --global "${prefix_args[@]}" "$PACKAGE@$version" || fail "npm package installation failed." +[[ -x "$install_bin" ]] || fail "the rea command was not installed at $install_bin." +installed_version="$("$install_bin" --version 2>/dev/null | tr -d '[:space:]')" || fail "could not read the installed REA version." [[ "$installed_version" == "$version" ]] || fail "installed version $installed_version does not match $version." -temporary_directory="$(mktemp -d "${TMPDIR:-/tmp}/rea-install.XXXXXX")" || fail "could not create a temporary directory." -setup_output="$temporary_directory/setup.json" -doctor_output="$temporary_directory/doctor.json" -rea setup --yes --json >"$setup_output" || fail "REA setup command failed." -rea doctor --json >"$doctor_output" || fail "REA doctor command failed." -result="$(node - "$setup_output" "$doctor_output" <<'NODE' -const fs = require("node:fs"); -const [setupPath, doctorPath] = process.argv.slice(2); -try { - const setup = JSON.parse(fs.readFileSync(setupPath, "utf8")); - const doctor = JSON.parse(fs.readFileSync(doctorPath, "utf8")); - if (!Array.isArray(doctor.checks) || typeof doctor.healthy !== "boolean") process.exit(2); - if (setup.status !== "ready" && setup.status !== "needs_human") process.exit(2); - const activation = setup.actions.includes("installed_hopper"); - if (!doctor.healthy && !activation) process.exit(3); - process.stdout.write(activation ? "activation" : "ready"); -} catch { process.exit(2); } -NODE -)" || fail "setup or doctor reported an unhealthy required component or malformed output." - -if [[ "$result" == "activation" ]]; then - printf 'REA %s is installed and coding agents are configured.\n' "$version" - printf 'One user action remains: open Hopper, complete its activation, then run: rea doctor --json\n' +printf 'REA %s is installed.\n' "$version" +if [[ "$start_setup" == true && -r /dev/tty && -w /dev/tty && -t 1 ]]; then + printf 'Starting REA setup...\n' + "$install_bin" setup /dev/tty else - printf 'REA %s is installed and ready. Run rea doctor --json at any time.\n' "$version" -fi -if [[ "$platform" == "Linux" ]]; then - printf 'Add %s to PATH to run rea from future shells.\n' "$HOME/.local/bin" + printf 'Run %s setup to configure Hopper and coding agents.\n' "$install_bin" fi diff --git a/package-lock.json b/package-lock.json index f7dc23bf2..a8c8717c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "pino": "9.14.0", "plist": "5.0.0", "smol-toml": "^1.7.0", - "write-file-atomic": "8.0.0", + "write-file-atomic": "7.0.0", "ws": "8.21.0", "zod": "4.4.3" }, @@ -50,8 +50,7 @@ "vitest": "4.1.10" }, "engines": { - "node": ">=24.18.0 <25", - "npm": ">=11.16.0 <12" + "node": "^22.19.0 || >=24.11.0" } }, "node_modules/@babel/helper-string-parser": { @@ -2814,6 +2813,15 @@ "node": ">= 4" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/incur": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/incur/-/incur-0.4.13.tgz", @@ -5023,15 +5031,16 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-8.0.0.tgz", - "integrity": "sha512-dYwyZredl67GyLLIHJnRM3h2PcOmN5SkcgC7eM5DPDEOEl6dLFqVrMg3F1Ea32usj4VSVZtd2H4MtKTNOf6nPg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz", + "integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==", "license": "ISC", "dependencies": { + "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" }, "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/ws": { diff --git a/package.json b/package.json index fad7d5e5d..f5f9cd565 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,7 @@ "rea": "scripts/rea.mjs" }, "engines": { - "node": ">=24.18.0 <25", - "npm": ">=11.16.0 <12" + "node": "^22.19.0 || >=24.11.0" }, "packageManager": "npm@11.16.0", "files": [ @@ -99,7 +98,7 @@ "pino": "9.14.0", "plist": "5.0.0", "smol-toml": "^1.7.0", - "write-file-atomic": "8.0.0", + "write-file-atomic": "7.0.0", "ws": "8.21.0", "zod": "4.4.3" }, diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 78d718082..ef193b86f 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -324,15 +324,32 @@ try { await writeFile(skillPath, "stale managed skill\n"); await writeFile(siblingSkillPath, "unrelated skill\n"); } + const planned = json(await run(cli, ["setup", "--json"], environment)); + if (supportedSetupHost) { + if ( + planned.status !== "needs_confirmation" || + planned.appliedActions.length !== 0 || + !planned.plannedActions.some(({ kind }) => kind === "configure_client") || + !planned.plannedActions.some(({ kind }) => kind === "install_skill") || + (await readFile(claudeConfig, "utf8")) !== '{"existing":true}\n' || + (await readFile(cursorConfig, "utf8")) !== '{"existing":true}\n' || + (await readFile(skillPath, "utf8")) !== "stale managed skill\n" + ) + throw new Error("packaged setup plan mutated files before approval"); + } const first = json(await run(cli, ["setup", "--yes", "--json"], environment)); const second = json( - await run(cli, ["setup", "--yes", "--json"], environment), + await run( + cli, + ["setup", "--yes", "--install-hopper", "--json"], + environment, + ), ); if (supportedSetupHost) { if ( first.status !== "ready" || second.status !== "ready" || - second.actions.length !== 0 + second.appliedActions.length !== 0 ) throw new Error("packaged setup was not ready and idempotent"); for (const configPath of [claudeConfig, cursorConfig]) { @@ -467,7 +484,7 @@ try { } process.stdout.write( - `${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "idempotent" : "unsupported-host-rejected", clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true })}\n`, + `${JSON.stringify({ cli: true, analysisCli: true, artifactCli: true, evidenceCli: true, incurMcpCommand: "npx -y rea-agents mcp", doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 2 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, skill: supportedSetupHost, mcpTools: expectedToolCount, evidenceMcp: true, targetFree: true, targetLifecycle: true })}\n`, ); } finally { if (tarball) await rm(join(root, tarball), { force: true }); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 7eff0f43a..3a737d85e 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -7,6 +7,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { parseBinaryTarget } from "../domain/binaryTarget.js"; +import { supportsNodeVersion } from "../domain/runtimeVersion.js"; import { probeHomebrew } from "./homebrew.js"; import { linuxHopperLauncherPath, @@ -60,10 +61,9 @@ export const runDoctor = async ( readonly checks: readonly DoctorCheck[]; }> => { const checks: DoctorCheck[] = []; - const nodeMajor = parseMajor(host.nodeVersion); checks.push( - check("node", nodeMajor >= 24, host.nodeVersion, { - remediation: "Install Node.js 24.18 or newer.", + check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, { + remediation: "Install Node.js 22.19+ or 24.11+.", classification: "missing_dependency", }), ); @@ -107,7 +107,7 @@ export const runDoctor = async ( checks.push( check("hopper", hopperPath !== undefined, hopperPath, { remediation: - "Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.", + "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.", classification: "missing_analysis_engine", }), ); diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts new file mode 100644 index 000000000..62e217719 --- /dev/null +++ b/src/application/MacHopper.ts @@ -0,0 +1,236 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { + access, + mkdir, + mkdtemp, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { z } from "zod"; + +const execFileAsync = promisify(execFile); +const RELEASES_URL = + "https://www.hopperapp.com/include/files-api.php?request=releases&public=true"; +const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/"; +const MAX_PACKAGE_BYTES = 100_000_000; +const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4"; + +/** Result of installing the official Hopper macOS application. */ +export type MacHopperInstallResult = + | { readonly status: "installed"; readonly launcherPath: string } + | { + readonly status: "failed"; + readonly reason: + | "release_metadata" + | "download" + | "integrity" + | "mount" + | "bundle" + | "destination_exists" + | "copy" + | "launcher_missing"; + }; + +/** External effects used by the bounded macOS Hopper installer. */ +export interface MacHopperInstallHost { + download( + url: string, + ): Promise<{ readonly ok: boolean; readonly bytes: Uint8Array }>; + createTemporaryDirectory(): Promise; + createMountDirectory(path: string): Promise; + writePackage(path: string, bytes: Uint8Array): Promise; + mount(packagePath: string, mountPath: string): Promise; + unmount(mountPath: string): Promise; + appBundle(mountPath: string): Promise; + validBundle(appPath: string): Promise; + destinationExists(path: string): Promise; + installBundle( + source: string, + destination: string, + stage: string, + ): Promise; + launcherReady(path: string): Promise; + openApplication(path: string): Promise; + cleanup(path: string): Promise; + destination(): string; +} + +/** Download, verify, and install Hopper into the current user's Applications directory. */ +export const installMacHopper = async ( + host: MacHopperInstallHost = systemMacHopperInstallHost(), +): Promise => { + let temporary: string | undefined; + let mounted = false; + try { + const metadata = await host.download(RELEASES_URL); + if (!metadata.ok) return { status: "failed", reason: "release_metadata" }; + const parsed = releasesSchema.safeParse(parseJson(metadata.bytes)); + if (!parsed.success) + return { status: "failed", reason: "release_metadata" }; + const release = parsed.data["OS X"]; + if (!release.filename.startsWith(DOWNLOAD_PREFIX)) + return { status: "failed", reason: "release_metadata" }; + const archive = await host.download(release.filename); + if (!archive.ok) return { status: "failed", reason: "download" }; + if (!integrityMatches(archive.bytes, release)) + return { status: "failed", reason: "integrity" }; + + const destination = host.destination(); + if (await host.destinationExists(destination)) + return { status: "failed", reason: "destination_exists" }; + temporary = await host.createTemporaryDirectory(); + const packagePath = join(temporary, "hopper.dmg"); + const mountPath = join(temporary, "mounted"); + await host.createMountDirectory(mountPath); + await host.writePackage(packagePath, archive.bytes); + if (!(await host.mount(packagePath, mountPath))) + return { status: "failed", reason: "mount" }; + mounted = true; + const source = await host.appBundle(mountPath); + if (source === undefined || !(await host.validBundle(source))) + return { status: "failed", reason: "bundle" }; + const stage = `${destination}.rea-stage`; + if (!(await host.installBundle(source, destination, stage))) + return { status: "failed", reason: "copy" }; + const launcherPath = join(destination, "Contents/MacOS/hopper"); + if (!(await host.launcherReady(launcherPath))) + return { status: "failed", reason: "launcher_missing" }; + await host.openApplication(destination); + return { status: "installed", launcherPath }; + } catch { + return { status: "failed", reason: "download" }; + } finally { + if (temporary !== undefined) { + const mountPath = join(temporary, "mounted"); + if (mounted) await host.unmount(mountPath); + await host.cleanup(temporary); + } + } +}; + +const systemMacHopperInstallHost = (): MacHopperInstallHost => ({ + async download(url) { + const response = await fetch(url, { + headers: { accept: "application/json", "user-agent": "rea-installer" }, + signal: AbortSignal.timeout(120_000), + }); + const length = Number(response.headers.get("content-length") ?? "0"); + if (length > MAX_PACKAGE_BYTES) + return { ok: false, bytes: new Uint8Array() }; + const bytes = new Uint8Array(await response.arrayBuffer()); + return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes }; + }, + createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")), + createMountDirectory: (path) => mkdir(path), + writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }), + async mount(packagePath, mountPath) { + return commandSucceeds("hdiutil", [ + "attach", + "-readonly", + "-nobrowse", + "-mountpoint", + mountPath, + packagePath, + ]); + }, + async unmount(mountPath) { + await commandSucceeds("hdiutil", ["detach", mountPath, "-force"]); + }, + async appBundle(mountPath) { + const entries = await readdir(mountPath, { withFileTypes: true }); + const app = entries.find( + (entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name), + ); + return app === undefined ? undefined : join(mountPath, app.name); + }, + async validBundle(appPath) { + if ( + !(await commandSucceeds("codesign", [ + "--verify", + "--deep", + "--strict", + appPath, + ])) + ) + return false; + try { + const identifier = ( + await execFileAsync("/usr/libexec/PlistBuddy", [ + "-c", + "Print :CFBundleIdentifier", + join(appPath, "Contents/Info.plist"), + ]) + ).stdout.trim(); + return identifier === BUNDLE_IDENTIFIER; + } catch { + return false; + } + }, + async destinationExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } + }, + async installBundle(source, destination, stage) { + try { + await mkdir(join(homedir(), "Applications"), { recursive: true }); + await rm(stage, { recursive: true, force: true }); + await execFileAsync("ditto", [source, stage]); + await rename(stage, destination); + return true; + } catch { + await rm(stage, { recursive: true, force: true }); + return false; + } + }, + async launcherReady(path) { + try { + await access(path); + return true; + } catch { + return false; + } + }, + async openApplication(path) { + await execFileAsync("open", [path]); + }, + cleanup: (path) => rm(path, { recursive: true, force: true }), + destination: () => join(homedir(), "Applications/Hopper Disassembler.app"), +}); + +const parseJson = (bytes: Uint8Array): unknown => + JSON.parse(new TextDecoder().decode(bytes)); +const integrityMatches = ( + bytes: Uint8Array, + release: z.infer, +): boolean => + bytes.byteLength === Number(release.file_length) && + bytes.byteLength <= MAX_PACKAGE_BYTES && + createHash("sha1").update(bytes).digest("hex") === release.file_hash; +const commandSucceeds = async ( + command: string, + args: readonly string[], +): Promise => { + try { + await execFileAsync(command, args); + return true; + } catch { + return false; + } +}; +const releaseSchema = z.object({ + filename: z.string().url(), + file_length: z.string().regex(/^\d+$/u), + file_hash: z.string().regex(/^[a-f0-9]{40}$/u), +}); +const releasesSchema = z.object({ "OS X": releaseSchema }); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index a408aee59..e60052b7b 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -9,22 +9,19 @@ import { } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; import { z } from "zod"; import writeFileAtomic from "write-file-atomic"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { PRODUCT_IDENTITY } from "../identity.js"; +import { supportsNodeVersion } from "../domain/runtimeVersion.js"; import { runDoctor, systemDoctorHost } from "./Doctor.js"; -import { probeHomebrew } from "./homebrew.js"; import { installLinuxHopper, readLinuxDistribution, type LinuxDistribution, } from "./LinuxHopper.js"; - -const execFileAsync = promisify(execFile); +import { installMacHopper } from "./MacHopper.js"; const registrationCommand = (): readonly string[] => process.env.npm_command === "exec" @@ -58,14 +55,12 @@ export interface SetupHost { readonly nodeVersion: string; macosVersion(): Promise; linuxDistribution(): Promise; - hasHomebrew(): Promise; - installHomebrew(): Promise; hopperPath(): Promise; - installHopper(): Promise; + installHopper(): Promise; detectedClients(): Promise; configureClient( client: SetupClient, - hopperPath: string, + hopperPath: string | undefined, command: readonly string[], ): Promise; installSkill(): Promise<"installed" | "unchanged" | "failed">; @@ -77,56 +72,126 @@ export interface SetupHost { * prompt, avoiding deadlocks in stdio and other unattended environments. */ export interface SetupResult { - readonly status: "ready" | "needs_human"; - readonly actions: readonly string[]; + readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human"; + readonly plannedActions: readonly SetupAction[]; + readonly appliedActions: readonly string[]; readonly clients: Readonly>; readonly doctor: Awaited>; readonly remediation?: string; } +/** One concrete setup mutation disclosed before approval. */ +export interface SetupAction { + readonly kind: "install_hopper" | "configure_client" | "install_skill"; + readonly target: string; + readonly detail: string; + readonly external: boolean; +} + +/** Explicit authorization supplied by an interactive or unattended CLI adapter. */ +export interface SetupOptions { + readonly approved: boolean; + readonly installHopper: boolean; + readonly structured: boolean; +} + +/** Adapter-owned interactive confirmation for a fully discovered setup plan. */ +export type SetupConfirmation = ( + actions: readonly SetupAction[], +) => Promise; + /** * Install prerequisites and configure detected clients idempotently. - * With `yes` false, mutations requiring consent are reported as `needs_human`; - * with `yes` true, external installers still fail closed if they require UI. + * Discovery always precedes mutation. Interactive confirmation or explicit + * unattended options authorize only the actions represented in the result. */ export const runSetup = async ( - yes: boolean, + options: SetupOptions, host: SetupHost = systemSetupHost(), + confirm?: SetupConfirmation, ): Promise => { - const actions: string[] = []; + const appliedActions: string[] = []; + let plannedActions: readonly SetupAction[] = []; const clients: Record = {}; const fail = async (remediation: string): Promise => ({ status: "needs_human", - actions, + plannedActions, + appliedActions, clients, doctor: await host.doctor(), remediation, }); const unsupported = await hostRemediation(host); if (unsupported !== undefined) return fail(unsupported); - if (host.platform === "darwin" && !(await host.hasHomebrew())) { - if (!yes) return fail("Re-run with --yes to install Homebrew."); - if (!(await host.installHomebrew())) - return fail( - "Homebrew installation was interrupted; resolve the installer prompt and re-run setup.", - ); - actions.push("installed_homebrew"); - } let hopperPath = await host.hopperPath(); - if (hopperPath === undefined) { - if (!yes) return fail("Re-run with --yes to install Hopper."); - if (!(await host.installHopper())) - return fail( - "Hopper installation was interrupted; resolve the system prompt and re-run setup.", - ); - actions.push("installed_hopper"); - hopperPath = await host.hopperPath(); - if (hopperPath === undefined) - return fail( - "Hopper installation completed but its launcher was not found.", - ); + const detectedClients = await host.detectedClients(); + plannedActions = [ + ...(hopperPath === undefined + ? [ + { + kind: "install_hopper" as const, + target: + host.platform === "darwin" + ? "~/Applications/Hopper Disassembler.app" + : "system package manager", + detail: + "Download the official Hopper package, verify it, install it, and open Hopper for activation.", + external: true, + }, + ] + : []), + ...detectedClients + .filter(({ format }) => format !== "unsupported") + .map( + (client): SetupAction => ({ + kind: "configure_client", + target: client.configPath, + detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`, + external: false, + }), + ), + { + kind: "install_skill", + target: "~/.agents/skills/rea-analysis/SKILL.md", + detail: "Install or update the bundled REA analysis skill.", + external: false, + }, + ]; + let approved = options.approved; + let interactiveApproval = false; + if (!approved && confirm !== undefined && !options.structured) { + interactiveApproval = await confirm(plannedActions); + approved = interactiveApproval; } - for (const client of await host.detectedClients()) { + if (!approved) + return { + status: confirm === undefined ? "needs_confirmation" : "planned", + plannedActions, + appliedActions, + clients, + doctor: await host.doctor(), + remediation: + "Review the setup plan, then rerun interactively or with --yes.", + }; + + if ( + hopperPath === undefined && + (interactiveApproval || options.installHopper) + ) { + hopperPath = await host.installHopper(); + if (hopperPath === undefined) + return { + status: "needs_human", + plannedActions, + appliedActions, + clients, + doctor: await host.doctor(), + remediation: + "Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.", + }; + appliedActions.push("installed_hopper"); + } + for (const client of detectedClients) { const result = await host.configureClient( client, hopperPath, @@ -138,18 +203,19 @@ export const runSetup = async ( `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`, ); if (result.status === "configured") - actions.push(`configured_${client.name}`); + appliedActions.push(`configured_${client.name}`); } const skill = await host.installSkill(); if (skill === "failed") return fail("Agent skill installation or readback failed."); - if (skill === "installed") actions.push("installed_skill"); + if (skill === "installed") appliedActions.push("installed_skill"); const doctor = await host.doctor(); - const activationRequired = actions.includes("installed_hopper"); + const activationRequired = appliedActions.includes("installed_hopper"); const ready = doctor.healthy && !activationRequired; return { status: ready ? "ready" : "needs_human", - actions, + plannedActions, + appliedActions, clients, doctor, ...(ready @@ -157,7 +223,9 @@ export const runSetup = async ( : { remediation: activationRequired ? "Open Hopper, complete its one-time activation, then rerun rea doctor --json." - : "Run rea doctor and apply each reported remediation.", + : hopperPath === undefined + ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis." + : "Run rea doctor and apply each reported remediation.", }), }; }; @@ -167,7 +235,8 @@ const hostRemediation = async ( ): Promise => { if (host.platform !== "darwin" && host.platform !== "linux") return "REA supports Hopper on macOS and selected 64-bit Linux distributions."; - if (major(host.nodeVersion) < 24) return "Install Node.js 24.18 or newer."; + if (!supportsNodeVersion(host.nodeVersion)) + return "Install Node.js 22.19+ or 24.11+ and rerun setup."; if (host.platform === "darwin") { const version = await host.macosVersion(); return version === undefined || major(version) < 12 @@ -179,7 +248,7 @@ const hostRemediation = async ( : "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux."; }; -/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */ +/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */ const systemSetupHost = (): SetupHost => { const doctorHost = systemDoctorHost(); return { @@ -187,21 +256,14 @@ const systemSetupHost = (): SetupHost => { nodeVersion: process.versions.node, macosVersion: () => doctorHost.macosVersion(), linuxDistribution: readLinuxDistribution, - hasHomebrew: () => brewSucceeds(["--version"]), - installHomebrew: () => - commandSucceeds( - "/bin/bash", - [ - "-c", - "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)", - ], - { ...process.env, NONINTERACTIVE: "1" }, - ), hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath, - installHopper: () => - process.platform === "linux" - ? installLinuxHopper().then(({ status }) => status === "installed") - : brewSucceeds(["install", "--cask", "hopper-disassembler"]), + installHopper: async () => { + const result = + process.platform === "linux" + ? await installLinuxHopper() + : await installMacHopper(); + return result.status === "installed" ? result.launcherPath : undefined; + }, detectedClients: () => detectClients(homedir()), configureClient: (client, hopperPath, command) => client.format === "unsupported" @@ -473,24 +535,6 @@ export const installCanonicalSkill = async ( } }; -const commandSucceeds = async ( - command: string, - args: readonly string[], - env?: NodeJS.ProcessEnv, -): Promise => { - try { - await execFileAsync(command, args, env === undefined ? {} : { env }); - return true; - } catch { - return false; - } -}; -const brewSucceeds = async (args: readonly string[]): Promise => { - const result = await probeHomebrew(async (command) => - (await commandSucceeds(command, args)) ? true : undefined, - ); - return result === true; -}; const major = (version: string): number => Number.parseInt(version.split(".")[0] ?? "0", 10); const exists = async (path: string): Promise => { diff --git a/src/cli.ts b/src/cli.ts index 49c8d84b0..ce07f8903 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ import { Cli, z } from "incur"; import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline/promises"; import { runDoctor } from "./application/Doctor.js"; import { @@ -7,7 +8,7 @@ import { runProviderAnalysis, runSessionStatus, } from "./application/DirectAnalysis.js"; -import { runSetup } from "./application/Setup.js"; +import { runSetup, type SetupAction } from "./application/Setup.js"; import { runUninstall } from "./application/Uninstall.js"; import { runUpgrade, systemUpgradeHost } from "./application/Upgrade.js"; import { PRODUCT_IDENTITY } from "./identity.js"; @@ -76,11 +77,27 @@ const registerCoreCommands = ( yes: z .boolean() .default(false) - .describe("Approve prerequisite installation"), + .describe("Approve user-owned setup actions without prompting"), + installHopper: z + .boolean() + .default(false) + .describe("Also approve Hopper installation with --yes"), }), - alias: { yes: "y" }, - run: ({ options }) => - logCliCommand(logger, "setup", () => runSetup(options.yes)), + alias: { yes: "y", installHopper: "install-hopper" }, + run: ({ options, formatExplicit }) => + logCliCommand(logger, "setup", () => + runSetup( + { + approved: options.yes, + installHopper: options.installHopper, + structured: formatExplicit, + }, + undefined, + options.yes || formatExplicit || process.stdin.isTTY !== true + ? undefined + : confirmSetup, + ), + ), }); cli.command("doctor", { description: "Check whether REA is ready", @@ -163,6 +180,28 @@ const registerCoreCommands = ( }); }; +const confirmSetup = async ( + actions: readonly SetupAction[], +): Promise => { + process.stdout.write("\nREA setup plan\n"); + for (const action of actions) + process.stdout.write( + ` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`, + ); + const prompt = createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + const answer = (await prompt.question("Continue? [Y/n] ")) + .trim() + .toLowerCase(); + return answer === "" || answer === "y" || answer === "yes"; + } finally { + prompt.close(); + } +}; + const registerXrefsCommand = ( cli: ReturnType, logger: Logger, diff --git a/src/domain/runtimeVersion.ts b/src/domain/runtimeVersion.ts new file mode 100644 index 000000000..0ab5647d7 --- /dev/null +++ b/src/domain/runtimeVersion.ts @@ -0,0 +1,10 @@ +/** Return whether a Node.js version satisfies REA's tested runtime families. */ +export const supportsNodeVersion = (version: string): boolean => { + const [majorText = "", minorText = ""] = version.split("."); + const major = Number.parseInt(majorText, 10); + const minor = Number.parseInt(minorText, 10); + return ( + (major === 22 && minor >= 19) || + (major >= 24 && (major !== 24 || minor >= 11)) + ); +}; diff --git a/tests/installScript.test.ts b/tests/installScript.test.ts index a1a918e00..156f2cb44 100644 --- a/tests/installScript.test.ts +++ b/tests/installScript.test.ts @@ -24,90 +24,54 @@ afterEach(async () => { }); describe("curl installer scenarios", () => { - it("installs a pinned release with closed stdin and cleans temporary files", async () => { + it("installs only a pinned REA CLI with closed stdin", async () => { const fixture = await createFixture(); - const result = await runInstaller(fixture, { REA_VERSION: "0.3.0" }); - expect(result.stdout).toContain("REA 0.3.0 is installed and ready"); + const result = await runInstaller(fixture, ["--version", "0.3.0"]); + expect(result.stdout).toContain("REA 0.3.0 is installed"); + expect(result.stdout).toContain("Run "); expect(await readFile(fixture.npmLog, "utf8")).toContain( - "rea-agents@0.3.0", + "install --global --prefix", ); + expect(await readFile(fixture.reaLog, "utf8")).toBe("--version\n"); expect(await readdir(fixture.temporary)).toEqual([]); }); - it("resolves and validates the latest rea-agents release tag", async () => { + it("accepts Node 25 without installing or replacing it", async () => { + const fixture = await createFixture(); + const result = await runInstaller(fixture, ["--version", "0.3.0"], { + FAKE_NODE_VERSION: "25.1.0", + }); + expect(result.stdout).toContain("Runtime: Node.js 25.1.0"); + }); + + it("resolves and validates the latest REA release tag", async () => { const fixture = await createFixture(); const result = await runInstaller(fixture); - expect(result.stdout).toContain("Installing rea-agents@0.3.0"); + expect(result.stdout).toContain("Version: 0.3.0"); }); - it("bootstraps checksum-verified Node 24 into the user prefix", async () => { + it("prints a dry run without invoking npm", async () => { const fixture = await createFixture(); - const result = await runInstaller(fixture, { - REA_VERSION: "0.3.0", - FAKE_OLD_NODE: "1", - FAKE_NODE_ARCHIVE: fixture.nodeArchive, - FAKE_NODE_SUMS: fixture.nodeSums, + const result = await runInstaller(fixture, [ + "--version", + "0.3.0", + "--dry-run", + ]); + expect(result.stdout).toContain("no changes made"); + await expect(readFile(fixture.npmLog, "utf8")).rejects.toMatchObject({ + code: "ENOENT", }); - expect(result.stdout).toContain("REA 0.3.0 is installed and ready"); - expect( - await readFile( - join(fixture.home, ".local/share/rea/node/bin/node"), - "utf8", - ), - ).toContain("24.18.0"); - }); - - it("reports fresh Hopper activation as partial success", async () => { - const fixture = await createFixture(); - const result = await runInstaller(fixture, { - REA_VERSION: "0.3.0", - FAKE_SETUP_ACTIONS: '["installed_hopper"]', - }); - expect(result.stdout).toContain("One user action remains"); - expect(result.stdout).toContain("open Hopper"); }); it.each([ - [ - "injected version", - { REA_VERSION: "0.3.0;touch /tmp/pwned" }, - "exact semantic version", - ], - [ - "npm failure", - { REA_VERSION: "0.3.0", FAKE_NPM_FAIL: "1" }, - "npm package installation failed", - ], - [ - "version mismatch", - { REA_VERSION: "0.3.0", FAKE_REA_VERSION: "9.9.9" }, - "does not match", - ], - [ - "malformed setup", - { REA_VERSION: "0.3.0", FAKE_SETUP_JSON: "not-json" }, - "malformed output", - ], - [ - "unhealthy doctor", - { REA_VERSION: "0.3.0", FAKE_DOCTOR_HEALTHY: "false" }, - "unhealthy required component", - ], + ["unsupported Node", { FAKE_NODE_VERSION: "20.0.0" }, "unsupported"], + ["npm failure", { FAKE_NPM_FAIL: "1" }, "npm package installation failed"], + ["version mismatch", { FAKE_REA_VERSION: "9.9.9" }, "does not match"], ] as const)("fails closed on %s", async (_name, overrides, message) => { const fixture = await createFixture(); - await expect(runInstaller(fixture, overrides)).rejects.toMatchObject({ - stderr: expect.stringContaining(message), - }); - }); - - it("is safe to rerun", async () => { - const fixture = await createFixture(); - await runInstaller(fixture, { REA_VERSION: "0.3.0" }); - await runInstaller(fixture, { REA_VERSION: "0.3.0" }); - expect( - (await readFile(fixture.npmLog, "utf8")).trim().split("\n"), - ).toHaveLength(2); - expect(await readdir(fixture.temporary)).toEqual([]); + await expect( + runInstaller(fixture, ["--version", "0.3.0"], overrides), + ).rejects.toMatchObject({ stderr: expect.stringContaining(message) }); }); }); @@ -116,8 +80,7 @@ interface InstallerFixture { readonly bin: string; readonly temporary: string; readonly npmLog: string; - readonly nodeArchive: string; - readonly nodeSums: string; + readonly reaLog: string; } const createFixture = async (): Promise => { @@ -127,52 +90,45 @@ const createFixture = async (): Promise => { const bin = join(root, "bin"); const temporary = join(root, "tmp"); const npmLog = join(root, "npm.log"); - const nodeFixture = join(root, "node-v24.18.0-linux-x64"); - const nodeArchive = `${nodeFixture}.tar.xz`; - const nodeSums = join(root, "SHASUMS256.txt"); + const reaLog = join(root, "rea.log"); await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]); - const realNode = process.execPath; - await mkdir(join(nodeFixture, "bin"), { recursive: true }); - await executable( - join(nodeFixture, "bin/node"), - `#!/bin/sh\nif [ "$1" = "-p" ]; then echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`, - ); - await executable( - join(nodeFixture, "bin/npm"), - '#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\nexit 0\n', - ); - await execFileAsync("tar", [ - "-cJf", - nodeArchive, - "-C", - root, - "node-v24.18.0-linux-x64", - ]); - const checksum = ( - await execFileAsync("sha256sum", [nodeArchive]) - ).stdout.split(" ")[0]; - await writeFile(nodeSums, `${checksum} node-v24.18.0-linux-x64.tar.xz\n`); await executable( join(bin, "uname"), '#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo Linux\n', ); await executable( join(bin, "node"), - `#!/bin/sh\nif [ "$1" = "-p" ]; then [ "\${FAKE_OLD_NODE:-}" = "1" ] && echo 20.0.0 || echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`, + `#!/bin/sh +if [ "$1" = "-p" ]; then printf '%s\n' "\${FAKE_NODE_VERSION:-24.18.0}"; else exec ${shellQuote(process.execPath)} "$@"; fi +`, ); await executable( join(bin, "npm"), - '#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\n[ "${FAKE_NPM_FAIL:-}" = "1" ] && exit 1\nexit 0\n', + `#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_NPM_LOG" +[ "\${FAKE_NPM_FAIL:-}" = "1" ] && exit 1 +prefix="$HOME/.local" +while [ "$#" -gt 0 ]; do + if [ "$1" = "--prefix" ]; then shift; prefix="$1"; fi + shift +done +mkdir -p "$prefix/bin" +cp "$FAKE_REA_SOURCE" "$prefix/bin/rea" +chmod +x "$prefix/bin/rea" +`, ); await executable( join(bin, "curl"), - '#!/bin/sh\nout=""\nurl=""\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then shift; out="$1"; else url="$1"; fi\n shift\ndone\ncase "$url" in\n *node-v24.18.0-linux-x64.tar.xz) cp "$FAKE_NODE_ARCHIVE" "$out" ;;\n *SHASUMS256.txt) cp "$FAKE_NODE_SUMS" "$out" ;;\n *) printf \'{"tag_name":"rea-agents-0.3.0"}\' ;;\nesac\n', + '#!/bin/sh\nprintf \'{"tag_name":"rea-agents-0.3.0"}\'\n', ); await executable( - join(bin, "rea"), - '#!/bin/sh\ncase "$1" in\n--version) printf "%s\\n" "${FAKE_REA_VERSION:-0.3.0}" ;;\nsetup) if [ -n "${FAKE_SETUP_JSON:-}" ]; then printf "%s\\n" "$FAKE_SETUP_JSON"; else printf \'{"status":"ready","actions":%s}\\n\' "${FAKE_SETUP_ACTIONS:-[]}"; fi ;;\ndoctor) printf \'{"healthy":%s,"checks":[]}\\n\' "${FAKE_DOCTOR_HEALTHY:-true}" ;;\nesac\n', + join(bin, "rea-source"), + `#!/bin/sh +printf '%s\n' "$*" >> "$FAKE_REA_LOG" +[ "$1" = "--version" ] && printf '%s\n' "\${FAKE_REA_VERSION:-0.3.0}" +`, ); - return { home, bin, temporary, npmLog, nodeArchive, nodeSums }; + return { home, bin, temporary, npmLog, reaLog }; }; const executable = async (path: string, source: string): Promise => { @@ -182,15 +138,18 @@ const executable = async (path: string, source: string): Promise => { const runInstaller = async ( fixture: InstallerFixture, + args: readonly string[] = [], overrides: Readonly> = {}, ): Promise<{ readonly stdout: string; readonly stderr: string }> => - execFileAsync("/bin/bash", [join(process.cwd(), "install.sh")], { + execFileAsync("/bin/bash", [join(process.cwd(), "install.sh"), ...args], { env: { ...process.env, HOME: fixture.home, TMPDIR: fixture.temporary, - PATH: `${fixture.bin}:${process.env.PATH ?? ""}`, + PATH: `${fixture.bin}:/usr/bin:/bin`, FAKE_NPM_LOG: fixture.npmLog, + FAKE_REA_LOG: fixture.reaLog, + FAKE_REA_SOURCE: join(fixture.bin, "rea-source"), ...overrides, }, }); diff --git a/tests/macHopper.test.ts b/tests/macHopper.test.ts new file mode 100644 index 000000000..7647f274f --- /dev/null +++ b/tests/macHopper.test.ts @@ -0,0 +1,110 @@ +import { createHash } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + installMacHopper, + type MacHopperInstallHost, +} from "../src/application/MacHopper.js"; + +class FakeMacHopperHost implements MacHopperInstallHost { + readonly archive = new TextEncoder().encode("hopper-dmg"); + archiveOk = true; + valid = true; + existing = false; + mountSucceeds = true; + copySucceeds = true; + launcherSucceeds = true; + badHash = false; + unmounts = 0; + cleanups = 0; + opens = 0; + + download = (url: string) => + Promise.resolve( + url.includes("files-api") + ? { + ok: true, + bytes: new TextEncoder().encode( + JSON.stringify({ + "OS X": { + filename: + "https://www.hopperapp.com:443/downloader/public/Hopper-demo.dmg", + file_length: String(this.archive.byteLength), + file_hash: this.badHash + ? "0".repeat(40) + : createHash("sha1").update(this.archive).digest("hex"), + }, + }), + ), + } + : { ok: this.archiveOk, bytes: this.archive }, + ); + createTemporaryDirectory = () => Promise.resolve("/tmp/rea-hopper-test"); + createMountDirectory = () => Promise.resolve(); + writePackage = () => Promise.resolve(); + mount = () => Promise.resolve(this.mountSucceeds); + unmount = () => { + this.unmounts += 1; + return Promise.resolve(); + }; + appBundle = () => Promise.resolve("/mounted/Hopper Disassembler.app"); + validBundle = () => Promise.resolve(this.valid); + destinationExists = () => Promise.resolve(this.existing); + installBundle = () => Promise.resolve(this.copySucceeds); + launcherReady = () => Promise.resolve(this.launcherSucceeds); + openApplication = () => { + this.opens += 1; + return Promise.resolve(); + }; + cleanup = () => { + this.cleanups += 1; + return Promise.resolve(); + }; + destination = () => "/home/user/Applications/Hopper Disassembler.app"; +} + +describe("macOS Hopper installation", () => { + it("installs, verifies, opens, and cleans the official package", async () => { + const host = new FakeMacHopperHost(); + await expect(installMacHopper(host)).resolves.toEqual({ + status: "installed", + launcherPath: + "/home/user/Applications/Hopper Disassembler.app/Contents/MacOS/hopper", + }); + expect(host.opens).toBe(1); + expect(host.unmounts).toBe(1); + expect(host.cleanups).toBe(1); + }); + + it("rejects an integrity mismatch before mounting", async () => { + const host = new FakeMacHopperHost(); + host.badHash = true; + await expect(installMacHopper(host)).resolves.toEqual({ + status: "failed", + reason: "integrity", + }); + expect(host.unmounts).toBe(0); + expect(host.cleanups).toBe(0); + }); + + it("refuses to overwrite an existing application", async () => { + const host = new FakeMacHopperHost(); + host.existing = true; + await expect(installMacHopper(host)).resolves.toEqual({ + status: "failed", + reason: "destination_exists", + }); + }); + + it("cleans and detaches after bundle validation fails", async () => { + const host = new FakeMacHopperHost(); + host.valid = false; + await expect(installMacHopper(host)).resolves.toEqual({ + status: "failed", + reason: "bundle", + }); + expect(host.unmounts).toBe(1); + expect(host.cleanups).toBe(1); + }); +}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts index 670b64e6a..f6c800367 100644 --- a/tests/setup.test.ts +++ b/tests/setup.test.ts @@ -5,26 +5,23 @@ import { type ClientConfigurationResult, type SetupClient, type SetupHost, + type SetupOptions, } from "../src/application/Setup.js"; import type { DoctorCheck } from "../src/application/Doctor.js"; import type { LinuxDistribution } from "../src/application/LinuxHopper.js"; class FakeSetupHost implements SetupHost { readonly platform: NodeJS.Platform; - readonly nodeVersion = "24.18.0"; + readonly nodeVersion = "25.1.0"; version: string | undefined = "14.5"; - brew = false; + distribution: LinuxDistribution | undefined; hopper: string | undefined; - homebrewInstallSucceeds = true; hopperInstallSucceeds = true; skill: "installed" | "unchanged" | "failed" = "installed"; clients: readonly SetupClient[] = []; clientResults = new Map(); - homebrewInstalls = 0; hopperInstalls = 0; configurations = 0; - configuredHopperPaths: string[] = []; - distribution: LinuxDistribution | undefined; constructor(platform: NodeJS.Platform = "darwin") { this.platform = platform; @@ -34,31 +31,20 @@ class FakeSetupHost implements SetupHost { Promise.resolve(this.version); linuxDistribution = (): Promise => Promise.resolve(this.distribution); - hasHomebrew = (): Promise => Promise.resolve(this.brew); - installHomebrew = (): Promise => { - this.homebrewInstalls += 1; - if (this.homebrewInstallSucceeds) this.brew = true; - return Promise.resolve(this.homebrewInstallSucceeds); - }; hopperPath = (): Promise => Promise.resolve(this.hopper); - installHopper = (): Promise => { + installHopper = (): Promise => { this.hopperInstalls += 1; if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper"; - return Promise.resolve(this.hopperInstallSucceeds); + return Promise.resolve(this.hopper); }; detectedClients = (): Promise => Promise.resolve(this.clients); configureClient = ( client: SetupClient, - hopperPath: string, ): Promise => { this.configurations += 1; - this.configuredHopperPaths.push(hopperPath); return Promise.resolve( - this.clientResults.get(client.name) ?? { - status: "configured", - backupPath: `${client.configPath}.backup`, - }, + this.clientResults.get(client.name) ?? { status: "configured" }, ); }; installSkill = (): Promise<"installed" | "unchanged" | "failed"> => @@ -69,40 +55,107 @@ class FakeSetupHost implements SetupHost { checks: readonly DoctorCheck[]; }> => Promise.resolve({ - healthy: - (this.platform === "linux" || this.brew) && this.hopper !== undefined, + healthy: this.hopper !== undefined, ...(this.hopper === undefined ? {} : { hopperPath: this.hopper }), checks: [], }); } +const options = (approved: boolean, installHopper = false): SetupOptions => ({ + approved, + installHopper, + structured: true, +}); + describe("setup workflow", () => { - it("installs Homebrew and Hopper on a clean approved machine", async () => { + it("returns a complete plan without mutation", async () => { const host = new FakeSetupHost(); - const result = await runSetup(true, host); + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + const result = await runSetup(options(false), host); + expect(result.status).toBe("needs_confirmation"); + expect(result.plannedActions.map(({ kind }) => kind)).toEqual([ + "install_hopper", + "configure_client", + "install_skill", + ]); + expect(host.hopperInstalls).toBe(0); + expect(host.configurations).toBe(0); + }); + + it("applies an accepted interactive plan including Hopper", async () => { + const host = new FakeSetupHost(); + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(true), + ); expect(result.status).toBe("needs_human"); - expect(result.actions).toEqual([ - "installed_homebrew", + expect(result.appliedActions).toEqual([ "installed_hopper", "installed_skill", ]); + expect(host.hopperInstalls).toBe(1); }); - it("installs Hopper without Homebrew on supported Linux", async () => { - const host = new FakeSetupHost("linux"); - host.distribution = { - id: "ubuntu", - versionId: "24.04", - packageFamily: "deb", - supported: true, - }; - const result = await runSetup(true, host); + it("declines an interactive plan without mutation", async () => { + const host = new FakeSetupHost(); + const result = await runSetup( + { ...options(false), structured: false }, + host, + () => Promise.resolve(false), + ); + expect(result.status).toBe("planned"); + expect(result.appliedActions).toEqual([]); + expect(host.hopperInstalls).toBe(0); + }); + + it("requires the Hopper flag for unattended setup", async () => { + const host = new FakeSetupHost(); + const result = await runSetup(options(true), host); expect(result.status).toBe("needs_human"); - expect(result.actions).toEqual(["installed_hopper", "installed_skill"]); - expect(host.homebrewInstalls).toBe(0); + expect(host.hopperInstalls).toBe(0); + expect(result.appliedActions).toEqual(["installed_skill"]); + expect(result.remediation).toContain("--install-hopper"); }); - it("rejects unsupported Linux before mutation", async () => { + it("installs Hopper when unattended authorization is explicit", async () => { + const host = new FakeSetupHost(); + const result = await runSetup(options(true, true), host); + expect(result.appliedActions).toEqual([ + "installed_hopper", + "installed_skill", + ]); + expect(host.hopperInstalls).toBe(1); + }); + + it("reuses existing Hopper and configures detected clients", async () => { + const host = new FakeSetupHost(); + host.hopper = "/Applications/Hopper"; + host.clients = [ + { name: "claude", configPath: "/claude.json" }, + { name: "cursor", configPath: "/cursor.json" }, + ]; + const result = await runSetup(options(true), host); + expect(result.status).toBe("ready"); + expect(result.appliedActions).toEqual([ + "configured_claude", + "configured_cursor", + "installed_skill", + ]); + expect(host.hopperInstalls).toBe(0); + }); + + it("reports a failed Hopper installation without configuring clients", async () => { + const host = new FakeSetupHost(); + host.hopperInstallSucceeds = false; + host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; + const result = await runSetup(options(true, true), host); + expect(result.status).toBe("needs_human"); + expect(result.remediation).toContain("failed"); + expect(host.configurations).toBe(0); + }); + + it("rejects unsupported hosts before mutation", async () => { const host = new FakeSetupHost("linux"); host.distribution = { id: "debian", @@ -110,103 +163,8 @@ describe("setup workflow", () => { packageFamily: "deb", supported: false, }; - const result = await runSetup(true, host); + const result = await runSetup(options(true, true), host); expect(result.status).toBe("needs_human"); expect(host.hopperInstalls).toBe(0); - expect(host.configurations).toBe(0); }); - - it("requires --yes before installing Homebrew", async () => { - const host = new FakeSetupHost(); - const result = await runSetup(false, host); - expect(result.status).toBe("needs_human"); - expect(host.homebrewInstalls).toBe(0); - expect(result.remediation).toContain("--yes"); - }); - - it("requires --yes before installing Hopper", async () => { - const host = new FakeSetupHost(); - host.brew = true; - const result = await runSetup(false, host); - expect(result.status).toBe("needs_human"); - expect(host.hopperInstalls).toBe(0); - expect(result.remediation).toContain("--yes"); - }); - - it("accepts existing Homebrew and manually installed Hopper", async () => { - const host = new FakeSetupHost(); - host.brew = true; - host.hopper = "/Applications/Manual Hopper"; - expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]); - expect(host.hopperInstalls).toBe(0); - expect(host.configuredHopperPaths).toEqual([]); - }); - - it.each(["homebrew", "hopper"] as const)( - "returns needs_human when %s installation is interrupted", - async (stage) => { - const host = new FakeSetupHost(); - if (stage === "homebrew") host.homebrewInstallSucceeds = false; - else { - host.brew = true; - host.hopperInstallSucceeds = false; - } - const result = await runSetup(true, host); - expect(result.status).toBe("needs_human"); - expect(result.remediation).toContain("interrupted"); - }, - ); - - it("configures every detected client and reports backup paths", async () => { - const host = new FakeSetupHost(); - host.brew = true; - host.hopper = "/Hopper"; - host.clients = [ - { name: "claude", configPath: "/claude.json" }, - { name: "cursor", configPath: "/cursor.json" }, - ]; - const result = await runSetup(true, host); - expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]); - expect(host.configurations).toBe(2); - expect(host.configuredHopperPaths).toEqual(["/Hopper", "/Hopper"]); - }); - - it.each(["backup", "write", "readback"] as const)( - "stops with precise remediation after a %s failure", - async (reason) => { - const host = new FakeSetupHost(); - host.brew = true; - host.hopper = "/Hopper"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; - host.clientResults.set("cursor", { status: "failed", reason }); - const result = await runSetup(true, host); - expect(result.status).toBe("needs_human"); - expect(result.remediation).toContain(reason); - }, - ); - - it("is idempotent on repeated setup", async () => { - const host = new FakeSetupHost(); - host.brew = true; - host.hopper = "/Hopper"; - host.skill = "unchanged"; - host.clients = [{ name: "cursor", configPath: "/cursor.json" }]; - host.clientResults.set("cursor", { status: "unchanged" }); - const first = await runSetup(true, host); - const second = await runSetup(true, host); - expect(first.actions).toEqual([]); - expect(second.actions).toEqual([]); - expect(host.homebrewInstalls).toBe(0); - expect(host.hopperInstalls).toBe(0); - }); - - it.each(["11.7", undefined])( - "rejects unsupported macOS %s before mutation", - async (version) => { - const host = new FakeSetupHost(); - host.version = version; - expect((await runSetup(true, host)).status).toBe("needs_human"); - expect(host.homebrewInstalls).toBe(0); - }, - ); });