feat(setup): make installation explicit and safe
This commit is contained in:
parent
1d1fe7b212
commit
3793889ce8
243
install.sh
243
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 >/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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
createMountDirectory(path: string): Promise<void>;
|
||||
writePackage(path: string, bytes: Uint8Array): Promise<void>;
|
||||
mount(packagePath: string, mountPath: string): Promise<boolean>;
|
||||
unmount(mountPath: string): Promise<void>;
|
||||
appBundle(mountPath: string): Promise<string | undefined>;
|
||||
validBundle(appPath: string): Promise<boolean>;
|
||||
destinationExists(path: string): Promise<boolean>;
|
||||
installBundle(
|
||||
source: string,
|
||||
destination: string,
|
||||
stage: string,
|
||||
): Promise<boolean>;
|
||||
launcherReady(path: string): Promise<boolean>;
|
||||
openApplication(path: string): Promise<void>;
|
||||
cleanup(path: string): Promise<void>;
|
||||
destination(): string;
|
||||
}
|
||||
|
||||
/** Download, verify, and install Hopper into the current user's Applications directory. */
|
||||
export const installMacHopper = async (
|
||||
host: MacHopperInstallHost = systemMacHopperInstallHost(),
|
||||
): Promise<MacHopperInstallResult> => {
|
||||
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<typeof releaseSchema>,
|
||||
): 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<boolean> => {
|
||||
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 });
|
||||
|
|
@ -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<string | undefined>;
|
||||
linuxDistribution(): Promise<LinuxDistribution | undefined>;
|
||||
hasHomebrew(): Promise<boolean>;
|
||||
installHomebrew(): Promise<boolean>;
|
||||
hopperPath(): Promise<string | undefined>;
|
||||
installHopper(): Promise<boolean>;
|
||||
installHopper(): Promise<string | undefined>;
|
||||
detectedClients(): Promise<readonly SetupClient[]>;
|
||||
configureClient(
|
||||
client: SetupClient,
|
||||
hopperPath: string,
|
||||
hopperPath: string | undefined,
|
||||
command: readonly string[],
|
||||
): Promise<ClientConfigurationResult>;
|
||||
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<Record<string, ClientConfigurationResult>>;
|
||||
readonly doctor: Awaited<ReturnType<typeof runDoctor>>;
|
||||
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<boolean>;
|
||||
|
||||
/**
|
||||
* 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<SetupResult> => {
|
||||
const actions: string[] = [];
|
||||
const appliedActions: string[] = [];
|
||||
let plannedActions: readonly SetupAction[] = [];
|
||||
const clients: Record<string, ClientConfigurationResult> = {};
|
||||
const fail = async (remediation: string): Promise<SetupResult> => ({
|
||||
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<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 (!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<boolean> => {
|
||||
try {
|
||||
await execFileAsync(command, args, env === undefined ? {} : { env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const brewSucceeds = async (args: readonly string[]): Promise<boolean> => {
|
||||
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<boolean> => {
|
||||
|
|
|
|||
49
src/cli.ts
49
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<boolean> => {
|
||||
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<typeof Cli.create>,
|
||||
logger: Logger,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
);
|
||||
};
|
||||
|
|
@ -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<InstallerFixture> => {
|
||||
|
|
@ -127,52 +90,45 @@ const createFixture = async (): Promise<InstallerFixture> => {
|
|||
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<void> => {
|
||||
|
|
@ -182,15 +138,18 @@ const executable = async (path: string, source: string): Promise<void> => {
|
|||
|
||||
const runInstaller = async (
|
||||
fixture: InstallerFixture,
|
||||
args: readonly string[] = [],
|
||||
overrides: Readonly<Record<string, string>> = {},
|
||||
): 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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, ClientConfigurationResult>();
|
||||
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<LinuxDistribution | undefined> =>
|
||||
Promise.resolve(this.distribution);
|
||||
hasHomebrew = (): Promise<boolean> => Promise.resolve(this.brew);
|
||||
installHomebrew = (): Promise<boolean> => {
|
||||
this.homebrewInstalls += 1;
|
||||
if (this.homebrewInstallSucceeds) this.brew = true;
|
||||
return Promise.resolve(this.homebrewInstallSucceeds);
|
||||
};
|
||||
hopperPath = (): Promise<string | undefined> => Promise.resolve(this.hopper);
|
||||
installHopper = (): Promise<boolean> => {
|
||||
installHopper = (): Promise<string | undefined> => {
|
||||
this.hopperInstalls += 1;
|
||||
if (this.hopperInstallSucceeds) this.hopper = "/manual/Hopper";
|
||||
return Promise.resolve(this.hopperInstallSucceeds);
|
||||
return Promise.resolve(this.hopper);
|
||||
};
|
||||
detectedClients = (): Promise<readonly SetupClient[]> =>
|
||||
Promise.resolve(this.clients);
|
||||
configureClient = (
|
||||
client: SetupClient,
|
||||
hopperPath: string,
|
||||
): Promise<ClientConfigurationResult> => {
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue