feat: add cross-platform installation lifecycle
This commit is contained in:
parent
af9794158d
commit
e651cb65db
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -8,15 +8,28 @@ import { promisify } from "node:util";
|
|||
|
||||
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { probeHomebrew } from "./homebrew.js";
|
||||
import {
|
||||
linuxHopperLauncherPath,
|
||||
linuxSharedLibrariesAvailable,
|
||||
readLinuxDistribution,
|
||||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const DEFAULT_HOPPER =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
|
||||
|
||||
/** One actionable environment diagnostic. */
|
||||
export interface DoctorCheck {
|
||||
readonly name: string;
|
||||
readonly ok: boolean;
|
||||
readonly classification:
|
||||
| "healthy"
|
||||
| "missing_dependency"
|
||||
| "unsupported_host"
|
||||
| "missing_analysis_engine"
|
||||
| "config_drift";
|
||||
readonly detail?: string;
|
||||
readonly remediation?: string;
|
||||
}
|
||||
|
|
@ -26,6 +39,7 @@ export interface DoctorHost {
|
|||
readonly nodeVersion: string;
|
||||
readonly configuredHopperPath?: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
validTarget(path: string): Promise<boolean>;
|
||||
executable(path: string): Promise<boolean>;
|
||||
brewHopperPath(): Promise<string | undefined>;
|
||||
|
|
@ -48,27 +62,39 @@ export const runDoctor = async (
|
|||
const checks: DoctorCheck[] = [];
|
||||
const nodeMajor = parseMajor(host.nodeVersion);
|
||||
checks.push(
|
||||
check(
|
||||
"node",
|
||||
nodeMajor >= 22,
|
||||
host.nodeVersion,
|
||||
"Install Node.js 22 or newer.",
|
||||
),
|
||||
check("node", nodeMajor >= 24, host.nodeVersion, {
|
||||
remediation: "Install Node.js 24.18 or newer.",
|
||||
classification: "missing_dependency",
|
||||
}),
|
||||
);
|
||||
const macosVersion =
|
||||
host.platform === "darwin" ? await host.macosVersion() : undefined;
|
||||
const macosMajor = macosVersion === undefined ? 0 : parseMajor(macosVersion);
|
||||
const linuxDistribution =
|
||||
host.platform === "linux" ? await host.linuxDistribution() : undefined;
|
||||
const supportedHost =
|
||||
(host.platform === "darwin" && macosMajor >= 12) ||
|
||||
(host.platform === "linux" && linuxDistribution?.supported === true);
|
||||
checks.push(
|
||||
check(
|
||||
"macos",
|
||||
host.platform === "darwin" && macosMajor >= 12,
|
||||
macosVersion ?? host.platform,
|
||||
"Hopper requires macOS 12 or newer.",
|
||||
"host",
|
||||
supportedHost,
|
||||
macosVersion ??
|
||||
(linuxDistribution === undefined
|
||||
? host.platform
|
||||
: `${linuxDistribution.id} ${linuxDistribution.versionId ?? "unknown"}`),
|
||||
{
|
||||
remediation:
|
||||
"REA supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.",
|
||||
classification: "unsupported_host",
|
||||
},
|
||||
),
|
||||
);
|
||||
const candidates = [
|
||||
host.configuredHopperPath,
|
||||
DEFAULT_HOPPER,
|
||||
SYSTEM_LINUX_HOPPER,
|
||||
linuxHopperLauncherPath(homedir()),
|
||||
...(await host.manualHopperPaths()),
|
||||
await host.brewHopperPath(),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
|
|
@ -79,21 +105,18 @@ export const runDoctor = async (
|
|||
break;
|
||||
}
|
||||
checks.push(
|
||||
check(
|
||||
"hopper",
|
||||
hopperPath !== undefined,
|
||||
hopperPath,
|
||||
"Install Hopper with: brew install --cask hopper-disassembler, or set HOPPER_LAUNCHER_PATH.",
|
||||
),
|
||||
check("hopper", hopperPath !== undefined, hopperPath, {
|
||||
remediation:
|
||||
"Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
||||
classification: "missing_analysis_engine",
|
||||
}),
|
||||
);
|
||||
if (target !== undefined)
|
||||
checks.push(
|
||||
check(
|
||||
"target",
|
||||
await host.validTarget(target),
|
||||
target,
|
||||
"Supply a readable local app or program path.",
|
||||
),
|
||||
check("target", await host.validTarget(target), target, {
|
||||
remediation: "Supply a readable local app or program path.",
|
||||
classification: "config_drift",
|
||||
}),
|
||||
);
|
||||
return {
|
||||
healthy: checks.every(({ ok }) => ok),
|
||||
|
|
@ -118,12 +141,20 @@ export const systemDoctorHost = (): DoctorHost => ({
|
|||
return undefined;
|
||||
}
|
||||
},
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
async validTarget(path) {
|
||||
return (await parseBinaryTarget(path)).ok;
|
||||
},
|
||||
async executable(path) {
|
||||
try {
|
||||
await access(path, constants.X_OK);
|
||||
if (process.platform === "linux") {
|
||||
const linked = await execFileAsync("ldd", [path]);
|
||||
if (
|
||||
!linuxSharedLibrariesAvailable(`${linked.stdout}\n${linked.stderr}`)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
|
@ -168,10 +199,14 @@ const check = (
|
|||
name: string,
|
||||
ok: boolean,
|
||||
detail: string | undefined,
|
||||
remediation: string,
|
||||
failure: {
|
||||
readonly remediation: string;
|
||||
readonly classification: Exclude<DoctorCheck["classification"], "healthy">;
|
||||
},
|
||||
): DoctorCheck => ({
|
||||
name,
|
||||
ok,
|
||||
classification: ok ? "healthy" : failure.classification,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
...(ok ? {} : { remediation }),
|
||||
...(ok ? {} : { remediation: failure.remediation }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const RELEASES_URL =
|
||||
"https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
||||
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
||||
const MAX_PACKAGE_BYTES = 100_000_000;
|
||||
|
||||
export type LinuxPackageFamily = "deb" | "rpm" | "arch";
|
||||
|
||||
/** Parsed Linux distribution information used for support and package selection. */
|
||||
export interface LinuxDistribution {
|
||||
readonly id: string;
|
||||
readonly versionId?: string;
|
||||
readonly packageFamily: LinuxPackageFamily | undefined;
|
||||
readonly supported: boolean;
|
||||
}
|
||||
|
||||
/** Download response kept independent of Node's global fetch implementation. */
|
||||
export interface LinuxHopperDownload {
|
||||
readonly ok: boolean;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/** External effects required to install the official Linux package. */
|
||||
export interface LinuxHopperInstallHost {
|
||||
distribution(): Promise<LinuxDistribution | undefined>;
|
||||
download(
|
||||
url: string,
|
||||
options: { readonly signal?: AbortSignal },
|
||||
): Promise<LinuxHopperDownload>;
|
||||
createTemporaryDirectory(): Promise<string>;
|
||||
writeArchive(path: string, bytes: Uint8Array): Promise<void>;
|
||||
installPackage(family: LinuxPackageFamily, archive: string): Promise<boolean>;
|
||||
launcherReady(path: string): Promise<boolean>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** Structured outcome for every expected Linux installation failure. */
|
||||
export type LinuxHopperInstallResult =
|
||||
| { readonly status: "installed"; readonly launcherPath: string }
|
||||
| {
|
||||
readonly status: "failed";
|
||||
readonly reason:
|
||||
| "unsupported_host"
|
||||
| "release_metadata"
|
||||
| "download"
|
||||
| "integrity"
|
||||
| "authorization_or_package_manager"
|
||||
| "launcher_missing"
|
||||
| "cancelled";
|
||||
};
|
||||
|
||||
/** Parse an os-release document without executing its shell syntax. */
|
||||
export const parseLinuxDistribution = (text: string): LinuxDistribution => {
|
||||
const fields = new Map<string, string>();
|
||||
for (const line of text.split("\n")) {
|
||||
const match = /^([A-Z_]+)=(.*)$/u.exec(line.trim());
|
||||
if (match === null) continue;
|
||||
const key = match[1];
|
||||
const encoded = match[2];
|
||||
if (key === undefined || encoded === undefined) continue;
|
||||
const value =
|
||||
encoded.startsWith('"') && encoded.endsWith('"')
|
||||
? encoded.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\")
|
||||
: encoded;
|
||||
fields.set(key, value);
|
||||
}
|
||||
const id = (fields.get("ID") ?? "unknown").toLowerCase();
|
||||
const versionId = fields.get("VERSION_ID");
|
||||
const packageFamily = packageFamilyFor(id, fields.get("ID_LIKE"));
|
||||
const supported =
|
||||
id === "arch" ||
|
||||
(id === "ubuntu" && versionAtLeast(versionId, 24)) ||
|
||||
(id === "fedora" && versionAtLeast(versionId, 41));
|
||||
return {
|
||||
id,
|
||||
...(versionId === undefined ? {} : { versionId }),
|
||||
packageFamily,
|
||||
supported,
|
||||
};
|
||||
};
|
||||
|
||||
/** Read and classify the current Linux distribution. */
|
||||
export const readLinuxDistribution = async (): Promise<
|
||||
LinuxDistribution | undefined
|
||||
> => {
|
||||
try {
|
||||
return parseLinuxDistribution(await readFile("/etc/os-release", "utf8"));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/** Download, verify, install, and read back the official Hopper Linux package. */
|
||||
export const installLinuxHopper = async (
|
||||
host: LinuxHopperInstallHost = systemLinuxHopperInstallHost(),
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<LinuxHopperInstallResult> => {
|
||||
if (options.signal?.aborted === true)
|
||||
return { status: "failed", reason: "cancelled" };
|
||||
const distribution = await host.distribution();
|
||||
if (
|
||||
distribution?.supported !== true ||
|
||||
distribution.packageFamily === undefined
|
||||
)
|
||||
return { status: "failed", reason: "unsupported_host" };
|
||||
let temporary: string | undefined;
|
||||
try {
|
||||
const metadataDownload = await host.download(RELEASES_URL, options);
|
||||
if (!metadataDownload.ok)
|
||||
return { status: "failed", reason: "release_metadata" };
|
||||
const releases = parseReleases(metadataDownload.bytes);
|
||||
if (releases === undefined)
|
||||
return { status: "failed", reason: "release_metadata" };
|
||||
const release = releases[releaseKey(distribution.packageFamily)];
|
||||
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
||||
return { status: "failed", reason: "release_metadata" };
|
||||
const archiveDownload = await host.download(release.filename, options);
|
||||
if (!archiveDownload.ok) return { status: "failed", reason: "download" };
|
||||
if (!packageIntegrityMatches(archiveDownload.bytes, release))
|
||||
return { status: "failed", reason: "integrity" };
|
||||
temporary = await host.createTemporaryDirectory();
|
||||
const archive = join(temporary, `hopper.${distribution.packageFamily}`);
|
||||
await host.writeArchive(archive, archiveDownload.bytes);
|
||||
if (!(await host.installPackage(distribution.packageFamily, archive)))
|
||||
return { status: "failed", reason: "authorization_or_package_manager" };
|
||||
const launcherPath = "/opt/hopper/bin/Hopper";
|
||||
if (!(await host.launcherReady(launcherPath)))
|
||||
return { status: "failed", reason: "launcher_missing" };
|
||||
return { status: "installed", launcherPath };
|
||||
} catch (cause: unknown) {
|
||||
if (isAbortCause(cause)) return { status: "failed", reason: "cancelled" };
|
||||
return { status: "failed", reason: "download" };
|
||||
} finally {
|
||||
if (temporary !== undefined) await host.cleanup(temporary);
|
||||
}
|
||||
};
|
||||
|
||||
/** Canonical launcher path for a legacy user-local Linux Hopper installation. */
|
||||
export const linuxHopperLauncherPath = (home: string): string =>
|
||||
join(home, ".local/share/rea/hopper/bin/Hopper");
|
||||
|
||||
const systemLinuxHopperInstallHost = (): LinuxHopperInstallHost => ({
|
||||
distribution: readLinuxDistribution,
|
||||
async download(url, options) {
|
||||
const response = await fetch(url, {
|
||||
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
return {
|
||||
ok: response.ok,
|
||||
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||
};
|
||||
},
|
||||
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-")),
|
||||
writeArchive: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
||||
installPackage: installSystemPackage,
|
||||
async launcherReady(path) {
|
||||
try {
|
||||
await access(path);
|
||||
const linked = await execFileAsync("ldd", [path]);
|
||||
return linuxSharedLibrariesAvailable(
|
||||
`${linked.stdout}\n${linked.stderr}`,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
||||
});
|
||||
|
||||
/** Interpret ldd output conservatively so a present but broken launcher is unhealthy. */
|
||||
export const linuxSharedLibrariesAvailable = (output: string): boolean =>
|
||||
!/(?:=>\s+not found|error while loading shared libraries)/iu.test(output);
|
||||
|
||||
const installSystemPackage = async (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const command = linuxPackageManagerCommand(
|
||||
family,
|
||||
archive,
|
||||
process.getuid?.() === 0,
|
||||
);
|
||||
await execFileAsync(command.executable, command.args);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Select the native package manager and authorization boundary without shell evaluation. */
|
||||
export const linuxPackageManagerCommand = (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
isRoot: boolean,
|
||||
): { readonly executable: string; readonly args: readonly string[] } => {
|
||||
const command =
|
||||
family === "deb"
|
||||
? ["apt-get", "install", "-y", archive]
|
||||
: family === "rpm"
|
||||
? ["dnf", "install", "-y", archive]
|
||||
: ["pacman", "-U", "--noconfirm", archive];
|
||||
const executable = isRoot ? command[0] : "pkexec";
|
||||
if (executable === undefined) throw new Error("package command is empty");
|
||||
return { executable, args: isRoot ? command.slice(1) : command };
|
||||
};
|
||||
|
||||
const parseReleases = (
|
||||
bytes: Uint8Array,
|
||||
): z.infer<typeof releasesSchema> | undefined => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
||||
const result = releasesSchema.safeParse(parsed);
|
||||
return result.success ? result.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const packageIntegrityMatches = (
|
||||
bytes: Uint8Array,
|
||||
release: z.infer<typeof releaseSchema>,
|
||||
): boolean =>
|
||||
bytes.byteLength === Number(release.file_length) &&
|
||||
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
||||
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
||||
|
||||
const packageFamilyFor = (
|
||||
id: string,
|
||||
idLike: string | undefined,
|
||||
): LinuxPackageFamily | undefined => {
|
||||
const identities = new Set([
|
||||
id,
|
||||
...(idLike?.toLowerCase().split(/\s+/u) ?? []),
|
||||
]);
|
||||
if (identities.has("ubuntu") || identities.has("debian")) return "deb";
|
||||
if (identities.has("fedora") || identities.has("rhel")) return "rpm";
|
||||
if (identities.has("arch")) return "arch";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const versionAtLeast = (
|
||||
version: string | undefined,
|
||||
minimum: number,
|
||||
): boolean =>
|
||||
version !== undefined &&
|
||||
Number.parseInt(version.split(".")[0] ?? "0", 10) >= minimum;
|
||||
const releaseKey = (
|
||||
family: LinuxPackageFamily,
|
||||
): "Ubuntu / Mint" | "Fedora" | "Arch" =>
|
||||
family === "deb" ? "Ubuntu / Mint" : family === "rpm" ? "Fedora" : "Arch";
|
||||
const isAbortCause = (cause: unknown): boolean =>
|
||||
cause instanceof Error && cause.name === "AbortError";
|
||||
const releaseSchema = z.object({
|
||||
filename: z.string().url(),
|
||||
file_length: z.string().regex(/^\d+$/u),
|
||||
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
});
|
||||
const releasesSchema = z.object({
|
||||
"Ubuntu / Mint": releaseSchema,
|
||||
Fedora: releaseSchema,
|
||||
Arch: releaseSchema,
|
||||
});
|
||||
|
|
@ -8,27 +8,40 @@ import {
|
|||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { runDoctor, systemDoctorHost } from "./Doctor.js";
|
||||
import { probeHomebrew } from "./homebrew.js";
|
||||
import {
|
||||
installLinuxHopper,
|
||||
readLinuxDistribution,
|
||||
type LinuxDistribution,
|
||||
} from "./LinuxHopper.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const registrationCommand = (): readonly string[] =>
|
||||
process.env.npm_command === "exec"
|
||||
? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
|
||||
: [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
|
||||
|
||||
/** A detected agent configuration owned by setup. */
|
||||
export interface SetupClient {
|
||||
readonly name: string;
|
||||
readonly configPath: string;
|
||||
readonly markerPath?: string;
|
||||
readonly format?: "json" | "toml" | "unsupported";
|
||||
}
|
||||
/** Result of one backup/write/readback transaction. */
|
||||
export type ClientConfigurationResult =
|
||||
| {
|
||||
readonly status: "unchanged" | "configured";
|
||||
readonly status: "unchanged" | "configured" | "skipped";
|
||||
readonly backupPath?: string;
|
||||
}
|
||||
| {
|
||||
|
|
@ -44,6 +57,7 @@ export interface SetupHost {
|
|||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
macosVersion(): Promise<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hasHomebrew(): Promise<boolean>;
|
||||
installHomebrew(): Promise<boolean>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
|
|
@ -52,6 +66,7 @@ export interface SetupHost {
|
|||
configureClient(
|
||||
client: SetupClient,
|
||||
hopperPath: string,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationResult>;
|
||||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
|
|
@ -87,13 +102,9 @@ export const runSetup = async (
|
|||
doctor: await host.doctor(),
|
||||
remediation,
|
||||
});
|
||||
if (host.platform !== "darwin")
|
||||
return fail("Hopper requires macOS 12 or newer.");
|
||||
const macosVersion = await host.macosVersion();
|
||||
if (macosVersion === undefined || major(macosVersion) < 12)
|
||||
return fail("Upgrade to macOS 12 or newer.");
|
||||
if (major(host.nodeVersion) < 22) return fail("Install Node.js 22 or newer.");
|
||||
if (!(await host.hasHomebrew())) {
|
||||
const unsupported = await hostRemediation(host);
|
||||
if (unsupported !== undefined) return fail(unsupported);
|
||||
if (host.platform === "darwin" && !(await host.hasHomebrew())) {
|
||||
if (!yes) return fail("Re-run with --yes to install Homebrew.");
|
||||
if (!(await host.installHomebrew()))
|
||||
return fail(
|
||||
|
|
@ -116,7 +127,11 @@ export const runSetup = async (
|
|||
);
|
||||
}
|
||||
for (const client of await host.detectedClients()) {
|
||||
const result = await host.configureClient(client, hopperPath);
|
||||
const result = await host.configureClient(
|
||||
client,
|
||||
hopperPath,
|
||||
registrationCommand(),
|
||||
);
|
||||
clients[client.name] = result;
|
||||
if (result.status === "failed")
|
||||
return fail(
|
||||
|
|
@ -130,19 +145,40 @@ export const runSetup = async (
|
|||
return fail("Agent skill installation or readback failed.");
|
||||
if (skill === "installed") actions.push("installed_skill");
|
||||
const doctor = await host.doctor();
|
||||
const activationRequired = actions.includes("installed_hopper");
|
||||
const ready = doctor.healthy && !activationRequired;
|
||||
return {
|
||||
status: doctor.healthy ? "ready" : "needs_human",
|
||||
status: ready ? "ready" : "needs_human",
|
||||
actions,
|
||||
clients,
|
||||
doctor,
|
||||
...(doctor.healthy
|
||||
...(ready
|
||||
? {}
|
||||
: {
|
||||
remediation: "Run rea doctor and apply each reported remediation.",
|
||||
remediation: activationRequired
|
||||
? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
|
||||
: "Run rea doctor and apply each reported remediation.",
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const hostRemediation = async (
|
||||
host: SetupHost,
|
||||
): Promise<string | undefined> => {
|
||||
if (host.platform !== "darwin" && host.platform !== "linux")
|
||||
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
||||
if (major(host.nodeVersion) < 24) return "Install Node.js 24.18 or newer.";
|
||||
if (host.platform === "darwin") {
|
||||
const version = await host.macosVersion();
|
||||
return version === undefined || major(version) < 12
|
||||
? "Upgrade to macOS 12 or newer."
|
||||
: undefined;
|
||||
}
|
||||
return (await host.linuxDistribution())?.supported === true
|
||||
? undefined
|
||||
: "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.";
|
||||
};
|
||||
|
||||
/** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
|
||||
const systemSetupHost = (): SetupHost => {
|
||||
const doctorHost = systemDoctorHost();
|
||||
|
|
@ -150,6 +186,7 @@ const systemSetupHost = (): SetupHost => {
|
|||
platform: process.platform,
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
linuxDistribution: readLinuxDistribution,
|
||||
hasHomebrew: () => brewSucceeds(["--version"]),
|
||||
installHomebrew: () =>
|
||||
commandSucceeds(
|
||||
|
|
@ -162,43 +199,86 @@ const systemSetupHost = (): SetupHost => {
|
|||
),
|
||||
hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
|
||||
installHopper: () =>
|
||||
brewSucceeds(["install", "--cask", "hopper-disassembler"]),
|
||||
detectedClients: () => detectJsonClients(homedir()),
|
||||
configureClient: configureJsonClient,
|
||||
process.platform === "linux"
|
||||
? installLinuxHopper().then(({ status }) => status === "installed")
|
||||
: brewSucceeds(["install", "--cask", "hopper-disassembler"]),
|
||||
detectedClients: () => detectClients(homedir()),
|
||||
configureClient: (client, hopperPath, command) =>
|
||||
client.format === "unsupported"
|
||||
? Promise.resolve({ status: "skipped" })
|
||||
: client.format === "toml"
|
||||
? configureTomlClient(client, hopperPath, command)
|
||||
: configureJsonClient(client, hopperPath, command),
|
||||
installSkill: () => installCanonicalSkill(homedir()),
|
||||
doctor: () => runDoctor(undefined, doctorHost),
|
||||
};
|
||||
};
|
||||
|
||||
const detectJsonClients = async (
|
||||
/** Detect supported coding agents from stable per-user installation markers. */
|
||||
export const detectClients = async (
|
||||
home: string,
|
||||
): Promise<readonly SetupClient[]> => {
|
||||
const candidates = [
|
||||
{
|
||||
name: "claude_desktop",
|
||||
configPath: join(
|
||||
home,
|
||||
"Library/Application Support/Claude/claude_desktop_config.json",
|
||||
),
|
||||
marker: join(home, "Library/Application Support/Claude"),
|
||||
},
|
||||
{
|
||||
name: "cursor",
|
||||
configPath: join(home, ".cursor/mcp.json"),
|
||||
marker: join(home, ".cursor"),
|
||||
},
|
||||
];
|
||||
const detected: SetupClient[] = [];
|
||||
for (const candidate of candidates)
|
||||
if (await exists(candidate.marker))
|
||||
detected.push({ name: candidate.name, configPath: candidate.configPath });
|
||||
for (const candidate of supportedClients(home))
|
||||
if (await exists(candidate.markerPath ?? candidate.configPath))
|
||||
detected.push(candidate);
|
||||
return detected;
|
||||
};
|
||||
|
||||
/** Describe every client location that setup or uninstall may own. */
|
||||
export const supportedClients = (home: string): readonly SetupClient[] => [
|
||||
{
|
||||
name: "claude_code",
|
||||
configPath: join(home, ".claude.json"),
|
||||
markerPath: join(home, ".claude"),
|
||||
},
|
||||
{
|
||||
name: "claude_desktop",
|
||||
configPath: join(
|
||||
home,
|
||||
"Library/Application Support/Claude/claude_desktop_config.json",
|
||||
),
|
||||
markerPath: join(home, "Library/Application Support/Claude"),
|
||||
},
|
||||
{
|
||||
name: "codex",
|
||||
configPath: join(home, ".codex/config.toml"),
|
||||
markerPath: join(home, ".codex"),
|
||||
format: "toml" as const,
|
||||
},
|
||||
{
|
||||
name: "cursor",
|
||||
configPath: join(home, ".cursor/mcp.json"),
|
||||
markerPath: join(home, ".cursor"),
|
||||
},
|
||||
{
|
||||
name: "gemini_cli",
|
||||
configPath: join(home, ".gemini/settings.json"),
|
||||
markerPath: join(home, ".gemini"),
|
||||
},
|
||||
{
|
||||
name: "windsurf",
|
||||
configPath: join(home, ".codeium/windsurf/mcp_config.json"),
|
||||
markerPath: join(home, ".codeium/windsurf"),
|
||||
},
|
||||
{
|
||||
name: "devin",
|
||||
configPath: join(home, ".devin"),
|
||||
markerPath: join(home, ".devin"),
|
||||
format: "unsupported" as const,
|
||||
},
|
||||
];
|
||||
|
||||
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
||||
export const configureJsonClient = async (
|
||||
client: SetupClient,
|
||||
hopperPath?: string,
|
||||
command: readonly string[] = [
|
||||
"npx",
|
||||
"-y",
|
||||
PRODUCT_IDENTITY.packageName,
|
||||
"mcp",
|
||||
],
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
let document: Record<string, unknown> = {};
|
||||
let original: string | undefined;
|
||||
|
|
@ -215,8 +295,8 @@ export const configureJsonClient = async (
|
|||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const desired = {
|
||||
command: "npx",
|
||||
args: ["-y", PRODUCT_IDENTITY.packageName, "mcp"],
|
||||
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
||||
args: command.slice(1),
|
||||
...(hopperPath === undefined
|
||||
? {}
|
||||
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
||||
|
|
@ -268,6 +348,81 @@ export const configureJsonClient = async (
|
|||
};
|
||||
};
|
||||
|
||||
/** Back up, atomically update, and semantically read back Codex TOML configuration. */
|
||||
export const configureTomlClient = async (
|
||||
client: SetupClient,
|
||||
hopperPath?: string,
|
||||
command: readonly string[] = [
|
||||
"npx",
|
||||
"-y",
|
||||
PRODUCT_IDENTITY.packageName,
|
||||
"mcp",
|
||||
],
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
let document: Record<string, unknown> = {};
|
||||
let original: string | undefined;
|
||||
try {
|
||||
original = await readFile(client.configPath, "utf8");
|
||||
document = objectSchema.parse(parseToml(original));
|
||||
} catch (cause: unknown) {
|
||||
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
||||
}
|
||||
let servers: Record<string, unknown>;
|
||||
try {
|
||||
servers = parseOptionalObject(document.mcp_servers);
|
||||
} catch {
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
const desired = {
|
||||
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
||||
args: command.slice(1),
|
||||
...(hopperPath === undefined
|
||||
? {}
|
||||
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
|
||||
};
|
||||
if (
|
||||
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===
|
||||
JSON.stringify(desired)
|
||||
)
|
||||
return { status: "unchanged" };
|
||||
const backupPath =
|
||||
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
if (backupPath !== undefined) await copyFile(client.configPath, backupPath);
|
||||
} catch {
|
||||
return { status: "failed", reason: "backup" };
|
||||
}
|
||||
document.mcp_servers = {
|
||||
...servers,
|
||||
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
||||
};
|
||||
try {
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFileAtomic(client.configPath, stringifyToml(document), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
const readback = objectSchema.parse(
|
||||
parseToml(await readFile(client.configPath, "utf8")),
|
||||
);
|
||||
if (
|
||||
JSON.stringify(
|
||||
parseOptionalObject(readback.mcp_servers)[
|
||||
PRODUCT_IDENTITY.mcpServerKey
|
||||
],
|
||||
) !== JSON.stringify(desired)
|
||||
)
|
||||
throw new Error("TOML readback mismatch");
|
||||
} catch {
|
||||
await restoreConfig(client.configPath, original);
|
||||
return { status: "failed", reason: "readback" };
|
||||
}
|
||||
return {
|
||||
status: "configured",
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
};
|
||||
};
|
||||
|
||||
/** Transactionally install or upgrade the versioned canonical REA skill. */
|
||||
export const installCanonicalSkill = async (
|
||||
home: string,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import { copyFile, lstat, readFile, rm } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, isAbsolute, join } from "node:path";
|
||||
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import writeFileAtomic from "write-file-atomic";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { supportedClients, type SetupClient } from "./Setup.js";
|
||||
|
||||
/** One explicitly classified uninstall action. */
|
||||
export interface UninstallItem {
|
||||
readonly name: string;
|
||||
readonly status: "removed" | "retained" | "skipped" | "failed";
|
||||
readonly detail: string;
|
||||
}
|
||||
|
||||
/** Structured, idempotent result of removing REA-owned state. */
|
||||
export interface UninstallResult {
|
||||
readonly status: "complete" | "failed";
|
||||
readonly items: readonly UninstallItem[];
|
||||
}
|
||||
|
||||
/** Filesystem boundary used by the contained uninstall workflow. */
|
||||
export interface UninstallHost {
|
||||
clients(): Promise<readonly SetupClient[]>;
|
||||
removeClient(client: SetupClient): Promise<UninstallItem>;
|
||||
removeSkill(): Promise<UninstallItem>;
|
||||
purgeData(): Promise<readonly UninstallItem[]>;
|
||||
}
|
||||
|
||||
/** Remove only REA-owned registrations and managed files, optionally including local state. */
|
||||
export const runUninstall = async (
|
||||
purgeData: boolean,
|
||||
host: UninstallHost = systemUninstallHost(),
|
||||
): Promise<UninstallResult> => {
|
||||
const items: UninstallItem[] = [];
|
||||
for (const client of await host.clients())
|
||||
items.push(await host.removeClient(client));
|
||||
items.push(await host.removeSkill());
|
||||
if (purgeData) items.push(...(await host.purgeData()));
|
||||
items.push({
|
||||
name: "analysis_engine",
|
||||
status: "retained",
|
||||
detail: "Hopper is not owned by REA uninstall.",
|
||||
});
|
||||
return {
|
||||
status: items.some(({ status }) => status === "failed")
|
||||
? "failed"
|
||||
: "complete",
|
||||
items,
|
||||
};
|
||||
};
|
||||
|
||||
/** Create uninstall effects contained to detected client configs and REA-owned paths. */
|
||||
export const systemUninstallHost = (home = homedir()): UninstallHost => ({
|
||||
clients: () => Promise.resolve(supportedClients(home)),
|
||||
removeClient,
|
||||
removeSkill: () =>
|
||||
removeManagedPath(
|
||||
join(home, ".agents/skills", PRODUCT_IDENTITY.skillName),
|
||||
"skill",
|
||||
),
|
||||
purgeData: async () => [
|
||||
await removeManagedPath(join(home, ".rea/cache"), "cache"),
|
||||
await removeManagedPath(join(home, ".rea/state"), "state"),
|
||||
],
|
||||
});
|
||||
|
||||
const removeClient = async (client: SetupClient): Promise<UninstallItem> => {
|
||||
if (client.format === "unsupported")
|
||||
return item(
|
||||
client.name,
|
||||
"skipped",
|
||||
"This client has no documented local MCP configuration boundary.",
|
||||
);
|
||||
let original: string;
|
||||
try {
|
||||
original = await readFile(client.configPath, "utf8");
|
||||
} catch (cause: unknown) {
|
||||
return isMissing(cause)
|
||||
? item(client.name, "skipped", "Configuration does not exist.")
|
||||
: item(client.name, "failed", "Configuration could not be read.");
|
||||
}
|
||||
let document: Record<string, unknown>;
|
||||
let key: "mcp_servers" | "mcpServers";
|
||||
let servers: Record<string, unknown>;
|
||||
try {
|
||||
document = objectSchema.parse(
|
||||
client.format === "toml" ? parseToml(original) : JSON.parse(original),
|
||||
);
|
||||
key = client.format === "toml" ? "mcp_servers" : "mcpServers";
|
||||
servers = optionalObject(document[key]);
|
||||
} catch {
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Malformed configuration was preserved without mutation.",
|
||||
);
|
||||
}
|
||||
const registration = servers[PRODUCT_IDENTITY.mcpServerKey];
|
||||
if (registration === undefined)
|
||||
return item(client.name, "skipped", "REA registration is absent.");
|
||||
if (!isOwnedRegistration(registration))
|
||||
return item(
|
||||
client.name,
|
||||
"retained",
|
||||
"The rea-named registration is not REA-owned.",
|
||||
);
|
||||
const remaining = { ...servers };
|
||||
delete remaining[PRODUCT_IDENTITY.mcpServerKey];
|
||||
document[key] = remaining;
|
||||
const backupPath = `${client.configPath}.rea.backup`;
|
||||
try {
|
||||
await copyFile(client.configPath, backupPath);
|
||||
} catch {
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
"Configuration backup failed; no mutation was attempted.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const encoded =
|
||||
client.format === "toml"
|
||||
? stringifyToml(document)
|
||||
: `${JSON.stringify(document, null, 2)}\n`;
|
||||
await writeFileAtomic(client.configPath, encoded, { encoding: "utf8" });
|
||||
const readback = objectSchema.parse(
|
||||
client.format === "toml"
|
||||
? parseToml(await readFile(client.configPath, "utf8"))
|
||||
: JSON.parse(await readFile(client.configPath, "utf8")),
|
||||
);
|
||||
if (PRODUCT_IDENTITY.mcpServerKey in optionalObject(readback[key]))
|
||||
throw new Error("registration readback mismatch");
|
||||
return item(
|
||||
client.name,
|
||||
"removed",
|
||||
`Removed registration from ${client.configPath}.`,
|
||||
);
|
||||
} catch {
|
||||
await writeFileAtomic(client.configPath, original, {
|
||||
encoding: "utf8",
|
||||
}).catch(() => undefined);
|
||||
return item(
|
||||
client.name,
|
||||
"failed",
|
||||
`Configuration update failed; the original was restored and ${backupPath} was retained.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Ownership rule for persistent MCP registrations created by REA setup. */
|
||||
const isOwnedRegistration = (value: unknown): boolean => {
|
||||
const parsed = registrationSchema.safeParse(value);
|
||||
if (!parsed.success) return false;
|
||||
const { command, args } = parsed.data;
|
||||
return (
|
||||
((command === "rea" ||
|
||||
(isAbsolute(command) && basename(command) === "rea")) &&
|
||||
args.length === 1 &&
|
||||
args[0] === "mcp") ||
|
||||
(command === "npx" &&
|
||||
JSON.stringify(args) ===
|
||||
JSON.stringify(["-y", PRODUCT_IDENTITY.packageName, "mcp"]))
|
||||
);
|
||||
};
|
||||
|
||||
const removeManagedPath = async (
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<UninstallItem> => {
|
||||
try {
|
||||
const stats = await lstat(path);
|
||||
if (stats.isSymbolicLink())
|
||||
return item(name, "retained", `Refused to follow symlink ${path}.`);
|
||||
await rm(path, { recursive: true });
|
||||
return item(name, "removed", `Removed ${path}.`);
|
||||
} catch (cause: unknown) {
|
||||
return isMissing(cause)
|
||||
? item(name, "skipped", `${path} does not exist.`)
|
||||
: item(name, "failed", `${path} could not be removed.`);
|
||||
}
|
||||
};
|
||||
|
||||
const item = (
|
||||
name: string,
|
||||
status: UninstallItem["status"],
|
||||
detail: string,
|
||||
): UninstallItem => ({ name, status, detail });
|
||||
const objectSchema = z.record(z.string(), z.unknown());
|
||||
const optionalObject = (value: unknown): Record<string, unknown> =>
|
||||
value === undefined ? {} : objectSchema.parse(value);
|
||||
const registrationSchema = z
|
||||
.object({ command: z.string(), args: z.array(z.string()) })
|
||||
.passthrough();
|
||||
const isMissing = (cause: unknown): boolean =>
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
||||
15
src/cli.ts
15
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<typeof Cli.create> => {
|
|||
: parseLogLevel(process.env.REA_LOG_LEVEL),
|
||||
);
|
||||
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
|
||||
version: "0.1.0",
|
||||
version: process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
|
||||
description:
|
||||
"Reverse engineer anything from your terminal or coding agent.",
|
||||
mcp: {
|
||||
|
|
@ -68,6 +69,18 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
|||
run: ({ options }) =>
|
||||
logCliCommand(logger, "doctor", () => runDoctor(options.target)),
|
||||
});
|
||||
cli.command("uninstall", {
|
||||
description: "Remove REA-owned agent configuration and skill files",
|
||||
options: z.object({
|
||||
purgeData: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Also remove REA caches and state"),
|
||||
}),
|
||||
alias: { purgeData: "purge-data" },
|
||||
run: ({ options }) =>
|
||||
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
|
||||
});
|
||||
cli.command("analyze", {
|
||||
description: "Get an overview of an app",
|
||||
args: z.object({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { runDoctor, type DoctorHost } from "../src/application/Doctor.js";
|
|||
|
||||
const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
|
||||
platform: "darwin",
|
||||
nodeVersion: "22.1.0",
|
||||
nodeVersion: "24.18.0",
|
||||
macosVersion: () => Promise.resolve("12.0"),
|
||||
linuxDistribution: () => Promise.resolve(undefined),
|
||||
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
executable: (path) => Promise.resolve(path.includes("Hopper")),
|
||||
brewHopperPath: () => Promise.resolve(undefined),
|
||||
|
|
@ -20,9 +21,7 @@ describe("doctor", () => {
|
|||
undefined,
|
||||
host({ macosVersion: () => Promise.resolve(version) }),
|
||||
);
|
||||
expect(result.checks.find(({ name }) => name === "macos")?.ok).toBe(
|
||||
false,
|
||||
);
|
||||
expect(result.checks.find(({ name }) => name === "host")?.ok).toBe(false);
|
||||
},
|
||||
);
|
||||
it("detects a manual configured Hopper before Homebrew", async () => {
|
||||
|
|
@ -36,6 +35,27 @@ describe("doctor", () => {
|
|||
expect(result.hopperPath).toBe("/manual/Hopper");
|
||||
expect(result.healthy).toBe(true);
|
||||
});
|
||||
it("accepts an officially supported Linux distribution", async () => {
|
||||
const path = "/home/user/.local/share/rea/hopper/bin/Hopper";
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
host({
|
||||
platform: "linux",
|
||||
macosVersion: () => Promise.resolve(undefined),
|
||||
linuxDistribution: () =>
|
||||
Promise.resolve({
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
}),
|
||||
configuredHopperPath: path,
|
||||
executable: (candidate) => Promise.resolve(candidate === path),
|
||||
}),
|
||||
);
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.hopperPath).toBe(path);
|
||||
});
|
||||
it("detects a Homebrew cask installed outside /Applications", async () => {
|
||||
const path =
|
||||
"/opt/homebrew/Caskroom/hopper/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("curl installer scenarios", () => {
|
||||
it("installs a pinned release with closed stdin and cleans temporary files", async () => {
|
||||
const fixture = await createFixture();
|
||||
const result = await runInstaller(fixture, { REA_VERSION: "0.3.0" });
|
||||
expect(result.stdout).toContain("REA 0.3.0 is installed and ready");
|
||||
expect(await readFile(fixture.npmLog, "utf8")).toContain(
|
||||
"rea-agents@0.3.0",
|
||||
);
|
||||
expect(await readdir(fixture.temporary)).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves and validates the latest rea-agents release tag", async () => {
|
||||
const fixture = await createFixture();
|
||||
const result = await runInstaller(fixture);
|
||||
expect(result.stdout).toContain("Installing rea-agents@0.3.0");
|
||||
});
|
||||
|
||||
it("bootstraps checksum-verified Node 24 into the user prefix", async () => {
|
||||
const fixture = await createFixture();
|
||||
const result = await runInstaller(fixture, {
|
||||
REA_VERSION: "0.3.0",
|
||||
FAKE_OLD_NODE: "1",
|
||||
FAKE_NODE_ARCHIVE: fixture.nodeArchive,
|
||||
FAKE_NODE_SUMS: fixture.nodeSums,
|
||||
});
|
||||
expect(result.stdout).toContain("REA 0.3.0 is installed and ready");
|
||||
expect(
|
||||
await readFile(
|
||||
join(fixture.home, ".local/share/rea/node/bin/node"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("24.18.0");
|
||||
});
|
||||
|
||||
it("reports fresh Hopper activation as partial success", async () => {
|
||||
const fixture = await createFixture();
|
||||
const result = await runInstaller(fixture, {
|
||||
REA_VERSION: "0.3.0",
|
||||
FAKE_SETUP_ACTIONS: '["installed_hopper"]',
|
||||
});
|
||||
expect(result.stdout).toContain("One user action remains");
|
||||
expect(result.stdout).toContain("open Hopper");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"injected version",
|
||||
{ REA_VERSION: "0.3.0;touch /tmp/pwned" },
|
||||
"exact semantic version",
|
||||
],
|
||||
[
|
||||
"npm failure",
|
||||
{ REA_VERSION: "0.3.0", FAKE_NPM_FAIL: "1" },
|
||||
"npm package installation failed",
|
||||
],
|
||||
[
|
||||
"version mismatch",
|
||||
{ REA_VERSION: "0.3.0", FAKE_REA_VERSION: "9.9.9" },
|
||||
"does not match",
|
||||
],
|
||||
[
|
||||
"malformed setup",
|
||||
{ REA_VERSION: "0.3.0", FAKE_SETUP_JSON: "not-json" },
|
||||
"malformed output",
|
||||
],
|
||||
[
|
||||
"unhealthy doctor",
|
||||
{ REA_VERSION: "0.3.0", FAKE_DOCTOR_HEALTHY: "false" },
|
||||
"unhealthy required component",
|
||||
],
|
||||
] as const)("fails closed on %s", async (_name, overrides, message) => {
|
||||
const fixture = await createFixture();
|
||||
await expect(runInstaller(fixture, overrides)).rejects.toMatchObject({
|
||||
stderr: expect.stringContaining(message),
|
||||
});
|
||||
});
|
||||
|
||||
it("is safe to rerun", async () => {
|
||||
const fixture = await createFixture();
|
||||
await runInstaller(fixture, { REA_VERSION: "0.3.0" });
|
||||
await runInstaller(fixture, { REA_VERSION: "0.3.0" });
|
||||
expect(
|
||||
(await readFile(fixture.npmLog, "utf8")).trim().split("\n"),
|
||||
).toHaveLength(2);
|
||||
expect(await readdir(fixture.temporary)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
interface InstallerFixture {
|
||||
readonly home: string;
|
||||
readonly bin: string;
|
||||
readonly temporary: string;
|
||||
readonly npmLog: string;
|
||||
readonly nodeArchive: string;
|
||||
readonly nodeSums: string;
|
||||
}
|
||||
|
||||
const createFixture = async (): Promise<InstallerFixture> => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-install-test-"));
|
||||
roots.push(root);
|
||||
const home = join(root, "home");
|
||||
const bin = join(root, "bin");
|
||||
const temporary = join(root, "tmp");
|
||||
const npmLog = join(root, "npm.log");
|
||||
const nodeFixture = join(root, "node-v24.18.0-linux-x64");
|
||||
const nodeArchive = `${nodeFixture}.tar.xz`;
|
||||
const nodeSums = join(root, "SHASUMS256.txt");
|
||||
await Promise.all([mkdir(home), mkdir(bin), mkdir(temporary)]);
|
||||
const realNode = process.execPath;
|
||||
await mkdir(join(nodeFixture, "bin"), { recursive: true });
|
||||
await executable(
|
||||
join(nodeFixture, "bin/node"),
|
||||
`#!/bin/sh\nif [ "$1" = "-p" ]; then echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`,
|
||||
);
|
||||
await executable(
|
||||
join(nodeFixture, "bin/npm"),
|
||||
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\nexit 0\n',
|
||||
);
|
||||
await execFileAsync("tar", [
|
||||
"-cJf",
|
||||
nodeArchive,
|
||||
"-C",
|
||||
root,
|
||||
"node-v24.18.0-linux-x64",
|
||||
]);
|
||||
const checksum = (
|
||||
await execFileAsync("sha256sum", [nodeArchive])
|
||||
).stdout.split(" ")[0];
|
||||
await writeFile(nodeSums, `${checksum} node-v24.18.0-linux-x64.tar.xz\n`);
|
||||
await executable(
|
||||
join(bin, "uname"),
|
||||
'#!/bin/sh\n[ "$1" = "-m" ] && echo x86_64 || echo Linux\n',
|
||||
);
|
||||
await executable(
|
||||
join(bin, "node"),
|
||||
`#!/bin/sh\nif [ "$1" = "-p" ]; then [ "\${FAKE_OLD_NODE:-}" = "1" ] && echo 20.0.0 || echo 24.18.0; else exec ${shellQuote(realNode)} "$@"; fi\n`,
|
||||
);
|
||||
await executable(
|
||||
join(bin, "npm"),
|
||||
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo 11.16.0; exit 0; fi\nprintf "%s\\n" "$*" >> "$FAKE_NPM_LOG"\n[ "${FAKE_NPM_FAIL:-}" = "1" ] && exit 1\nexit 0\n',
|
||||
);
|
||||
await executable(
|
||||
join(bin, "curl"),
|
||||
'#!/bin/sh\nout=""\nurl=""\nwhile [ "$#" -gt 0 ]; do\n if [ "$1" = "-o" ]; then shift; out="$1"; else url="$1"; fi\n shift\ndone\ncase "$url" in\n *node-v24.18.0-linux-x64.tar.xz) cp "$FAKE_NODE_ARCHIVE" "$out" ;;\n *SHASUMS256.txt) cp "$FAKE_NODE_SUMS" "$out" ;;\n *) printf \'{"tag_name":"rea-agents-0.3.0"}\' ;;\nesac\n',
|
||||
);
|
||||
await executable(
|
||||
join(bin, "rea"),
|
||||
'#!/bin/sh\ncase "$1" in\n--version) printf "%s\\n" "${FAKE_REA_VERSION:-0.3.0}" ;;\nsetup) if [ -n "${FAKE_SETUP_JSON:-}" ]; then printf "%s\\n" "$FAKE_SETUP_JSON"; else printf \'{"status":"ready","actions":%s}\\n\' "${FAKE_SETUP_ACTIONS:-[]}"; fi ;;\ndoctor) printf \'{"healthy":%s,"checks":[]}\\n\' "${FAKE_DOCTOR_HEALTHY:-true}" ;;\nesac\n',
|
||||
);
|
||||
return { home, bin, temporary, npmLog, nodeArchive, nodeSums };
|
||||
};
|
||||
|
||||
const executable = async (path: string, source: string): Promise<void> => {
|
||||
await writeFile(path, source);
|
||||
await chmod(path, 0o755);
|
||||
};
|
||||
|
||||
const runInstaller = async (
|
||||
fixture: InstallerFixture,
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
|
||||
execFileAsync("/bin/bash", [join(process.cwd(), "install.sh")], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fixture.home,
|
||||
TMPDIR: fixture.temporary,
|
||||
PATH: `${fixture.bin}:${process.env.PATH ?? ""}`,
|
||||
FAKE_NPM_LOG: fixture.npmLog,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
installLinuxHopper,
|
||||
linuxPackageManagerCommand,
|
||||
linuxSharedLibrariesAvailable,
|
||||
parseLinuxDistribution,
|
||||
type LinuxDistribution,
|
||||
type LinuxHopperDownload,
|
||||
type LinuxHopperInstallHost,
|
||||
type LinuxPackageFamily,
|
||||
} from "../src/application/LinuxHopper.js";
|
||||
|
||||
class RecordingLinuxHost implements LinuxHopperInstallHost {
|
||||
distributionValue: LinuxDistribution | undefined = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
metadata: unknown = releasesFor(new Uint8Array([1, 2, 3]));
|
||||
archive = new Uint8Array([1, 2, 3]);
|
||||
metadataOk = true;
|
||||
archiveOk = true;
|
||||
installSucceeds = true;
|
||||
launcherExists = true;
|
||||
downloads: string[] = [];
|
||||
installed: Array<{ family: LinuxPackageFamily; archive: string }> = [];
|
||||
cleaned: string[] = [];
|
||||
|
||||
distribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distributionValue);
|
||||
download = (url: string): Promise<LinuxHopperDownload> => {
|
||||
this.downloads.push(url);
|
||||
if (url.includes("files-api.php"))
|
||||
return Promise.resolve({
|
||||
ok: this.metadataOk,
|
||||
bytes: new TextEncoder().encode(JSON.stringify(this.metadata)),
|
||||
});
|
||||
return Promise.resolve({ ok: this.archiveOk, bytes: this.archive });
|
||||
};
|
||||
createTemporaryDirectory = (): Promise<string> =>
|
||||
Promise.resolve("/tmp/rea test");
|
||||
writeArchive = (): Promise<void> => Promise.resolve();
|
||||
installPackage = (
|
||||
family: LinuxPackageFamily,
|
||||
archive: string,
|
||||
): Promise<boolean> => {
|
||||
this.installed.push({ family, archive });
|
||||
return Promise.resolve(this.installSucceeds);
|
||||
};
|
||||
launcherReady = (): Promise<boolean> => Promise.resolve(this.launcherExists);
|
||||
cleanup = (path: string): Promise<void> => {
|
||||
this.cleaned.push(path);
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
describe("Linux Hopper host classification", () => {
|
||||
it.each([
|
||||
['ID=ubuntu\nVERSION_ID="24.04"\n', "deb"],
|
||||
["ID=fedora\nVERSION_ID=41\n", "rpm"],
|
||||
["ID=arch\n", "arch"],
|
||||
] as const)("accepts an official Hopper distribution", (document, family) => {
|
||||
expect(parseLinuxDistribution(document)).toMatchObject({
|
||||
packageFamily: family,
|
||||
supported: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects unresolved or loader-reported shared libraries", () => {
|
||||
expect(
|
||||
linuxSharedLibrariesAvailable(
|
||||
"libQt6Network.so.6 => not found\nlibc.so.6 => /lib/libc.so.6",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
linuxSharedLibrariesAvailable(
|
||||
"error while loading shared libraries: libQt6Gui.so.6",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
linuxSharedLibrariesAvailable(
|
||||
"libQt6Network.so.6 => /usr/lib/libQt6Network.so.6",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'ID=ubuntu\nVERSION_ID="22.04"\n',
|
||||
"ID=fedora\nVERSION_ID=40\n",
|
||||
'ID=debian\nVERSION_ID="13"\nID_LIKE=debian\n',
|
||||
])("rejects unsupported vendor/version combinations", (document) => {
|
||||
expect(parseLinuxDistribution(document).supported).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Linux Hopper installation", () => {
|
||||
it.each([
|
||||
["deb", "apt-get"],
|
||||
["rpm", "dnf"],
|
||||
["arch", "pacman"],
|
||||
] as const)("selects the %s native package manager", (family, executable) => {
|
||||
expect(
|
||||
linuxPackageManagerCommand(family, "/tmp/Hopper package", true),
|
||||
).toMatchObject({
|
||||
executable,
|
||||
});
|
||||
expect(
|
||||
linuxPackageManagerCommand(family, "/tmp/Hopper package", false),
|
||||
).toMatchObject({
|
||||
executable: "pkexec",
|
||||
args: [
|
||||
executable,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["deb", "rpm", "arch"] as const)(
|
||||
"downloads, verifies, installs, reads back, and cleans a %s package",
|
||||
async (family) => {
|
||||
const host = new RecordingLinuxHost();
|
||||
host.distributionValue = {
|
||||
id: family,
|
||||
packageFamily: family,
|
||||
supported: true,
|
||||
};
|
||||
const result = await installLinuxHopper(host);
|
||||
expect(result).toEqual({
|
||||
status: "installed",
|
||||
launcherPath: "/opt/hopper/bin/Hopper",
|
||||
});
|
||||
expect(host.installed).toEqual([
|
||||
{ family, archive: `/tmp/rea test/hopper.${family}` },
|
||||
]);
|
||||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["unsupported host", "unsupported_host"],
|
||||
["malformed metadata", "release_metadata"],
|
||||
["unexpected origin", "release_metadata"],
|
||||
["metadata request failure", "release_metadata"],
|
||||
["package request failure", "download"],
|
||||
["checksum mismatch", "integrity"],
|
||||
["package manager failure", "authorization_or_package_manager"],
|
||||
["missing launcher", "launcher_missing"],
|
||||
] as const)("classifies %s", async (scenario, reason) => {
|
||||
const host = new RecordingLinuxHost();
|
||||
if (scenario === "unsupported host")
|
||||
host.distributionValue = {
|
||||
id: "debian",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
};
|
||||
if (scenario === "malformed metadata") host.metadata = { invalid: true };
|
||||
if (scenario === "unexpected origin") {
|
||||
const metadata = releasesFor(host.archive);
|
||||
const ubuntu = metadata["Ubuntu / Mint"];
|
||||
if (ubuntu === undefined)
|
||||
throw new Error("fixture omitted Ubuntu release");
|
||||
ubuntu.filename = "https://example.com/Hopper.deb";
|
||||
host.metadata = metadata;
|
||||
}
|
||||
if (scenario === "metadata request failure") host.metadataOk = false;
|
||||
if (scenario === "package request failure") host.archiveOk = false;
|
||||
if (scenario === "checksum mismatch") host.archive = new Uint8Array([9]);
|
||||
if (scenario === "package manager failure") host.installSucceeds = false;
|
||||
if (scenario === "missing launcher") host.launcherExists = false;
|
||||
expect(await installLinuxHopper(host)).toEqual({
|
||||
status: "failed",
|
||||
reason,
|
||||
});
|
||||
if (host.installed.length > 0)
|
||||
expect(host.cleaned).toEqual(["/tmp/rea test"]);
|
||||
});
|
||||
|
||||
it("cancels before network or mutation", async () => {
|
||||
const host = new RecordingLinuxHost();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(
|
||||
await installLinuxHopper(host, { signal: controller.signal }),
|
||||
).toEqual({
|
||||
status: "failed",
|
||||
reason: "cancelled",
|
||||
});
|
||||
expect(host.downloads).toEqual([]);
|
||||
expect(host.installed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function releasesFor(
|
||||
bytes: Uint8Array,
|
||||
): Record<
|
||||
string,
|
||||
{ filename: string; file_length: string; file_hash: string }
|
||||
> {
|
||||
const release = (extension: string) => ({
|
||||
filename: `https://www.hopperapp.com:443/downloader/public/Hopper.${extension}`,
|
||||
file_length: String(bytes.byteLength),
|
||||
file_hash: createHash("sha1").update(bytes).digest("hex"),
|
||||
});
|
||||
return {
|
||||
"Ubuntu / Mint": release("deb"),
|
||||
Fedora: release("rpm"),
|
||||
Arch: release("pkg.tar.xz"),
|
||||
};
|
||||
}
|
||||
|
|
@ -7,10 +7,11 @@ import {
|
|||
type SetupHost,
|
||||
} from "../src/application/Setup.js";
|
||||
import type { DoctorCheck } from "../src/application/Doctor.js";
|
||||
import type { LinuxDistribution } from "../src/application/LinuxHopper.js";
|
||||
|
||||
class FakeSetupHost implements SetupHost {
|
||||
readonly platform = "darwin" as const;
|
||||
readonly nodeVersion = "22.4.0";
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion = "24.18.0";
|
||||
version: string | undefined = "14.5";
|
||||
brew = false;
|
||||
hopper: string | undefined;
|
||||
|
|
@ -23,9 +24,16 @@ class FakeSetupHost implements SetupHost {
|
|||
hopperInstalls = 0;
|
||||
configurations = 0;
|
||||
configuredHopperPaths: string[] = [];
|
||||
distribution: LinuxDistribution | undefined;
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
}
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
Promise.resolve(this.version);
|
||||
linuxDistribution = (): Promise<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distribution);
|
||||
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
|
||||
installHomebrew = (): Promise<boolean> => {
|
||||
this.homebrewInstalls += 1;
|
||||
|
|
@ -61,7 +69,8 @@ class FakeSetupHost implements SetupHost {
|
|||
checks: readonly DoctorCheck[];
|
||||
}> =>
|
||||
Promise.resolve({
|
||||
healthy: this.brew && this.hopper !== undefined,
|
||||
healthy:
|
||||
(this.platform === "linux" || this.brew) && this.hopper !== undefined,
|
||||
...(this.hopper === undefined ? {} : { hopperPath: this.hopper }),
|
||||
checks: [],
|
||||
});
|
||||
|
|
@ -71,7 +80,7 @@ describe("setup workflow", () => {
|
|||
it("installs Homebrew and Hopper on a clean approved machine", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(true, host);
|
||||
expect(result.status).toBe("ready");
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.actions).toEqual([
|
||||
"installed_homebrew",
|
||||
"installed_hopper",
|
||||
|
|
@ -79,6 +88,34 @@ describe("setup workflow", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("installs Hopper without Homebrew on supported Linux", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "ubuntu",
|
||||
versionId: "24.04",
|
||||
packageFamily: "deb",
|
||||
supported: true,
|
||||
};
|
||||
const result = await runSetup(true, host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(result.actions).toEqual(["installed_hopper", "installed_skill"]);
|
||||
expect(host.homebrewInstalls).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects unsupported Linux before mutation", async () => {
|
||||
const host = new FakeSetupHost("linux");
|
||||
host.distribution = {
|
||||
id: "debian",
|
||||
versionId: "13",
|
||||
packageFamily: "deb",
|
||||
supported: false,
|
||||
};
|
||||
const result = await runSetup(true, host);
|
||||
expect(result.status).toBe("needs_human");
|
||||
expect(host.hopperInstalls).toBe(0);
|
||||
expect(host.configurations).toBe(0);
|
||||
});
|
||||
|
||||
it("requires --yes before installing Homebrew", async () => {
|
||||
const host = new FakeSetupHost();
|
||||
const result = await runSetup(false, host);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue