rea-agents 0.5.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,14 +11,14 @@
11
11
  [![npm version](https://img.shields.io/npm/v/rea-agents?style=flat-square&color=cb3837)](https://www.npmjs.com/package/rea-agents)
12
12
  [![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
13
13
  [![68 MCP tools](https://img.shields.io/badge/MCP_tools-68-5c4ee5?style=flat-square)](#68-tools-for-investigation)
14
- [![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
14
+ [![Node.js 22+](https://img.shields.io/badge/Node.js-22.19%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
15
15
  [![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
16
16
 
17
17
  [Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [68 tools](#68-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
18
18
 
19
19
  <br />
20
20
 
21
- <code>curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash</code>
21
+ <code>npm install --global rea-agents && rea setup</code>
22
22
 
23
23
  </div>
24
24
 
@@ -81,15 +81,24 @@ REA shows how it reached its conclusions. It does not claim to recover original
81
81
 
82
82
  ## Quick start
83
83
 
84
- ### One-command install — recommended
84
+ ### Install the CLI — recommended
85
85
 
86
86
  ```bash
87
- curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
87
+ npm install --global rea-agents
88
+ rea setup
88
89
  ```
89
90
 
90
- The installer supports macOS 12+, Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux. It installs compatible Node.js 24/npm 11 prerequisites, the exact latest `rea-agents-*` release, the official Hopper package, detected client registrations, and the bundled skill, then verifies the result. macOS uses Homebrew; Linux uses the native package manager and may show a system authorization prompt. Pin a reproducible release with `REA_VERSION=0.3.0`; `REA_VERSION` is the only supported installer environment override.
91
+ Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or coding-agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
92
+
93
+ REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. Registrations are additive, backup-first, and read back after writing. You can safely rerun setup.
94
+
95
+ An optional curl wrapper installs the same CLI package and starts setup only when a terminal is available:
96
+
97
+ ```bash
98
+ curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
99
+ ```
91
100
 
92
- REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. It configures detected clients with documented local MCP files; Devin is reported as skipped because its public documentation does not define a local configuration file. You can safely rerun the installer or `rea setup --yes`. If REA's local analysis engine needs one-time activation, installation remains intact and setup reports the exact remaining action rather than claiming full readiness.
101
+ Pass installer options after `bash -s --`, for example `--dry-run`, `--no-setup`, or `--version 1.0.0`. The curl wrapper never installs prerequisites or configures integrations itself. See [Installation and setup](docs/installation.md) for its exact mutation boundary.
93
102
 
94
103
  ### With a coding agent — recommended
95
104
 
@@ -99,36 +108,48 @@ npx skills add morluto/rea
99
108
 
100
109
  Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset.
101
110
 
102
- Approve installation if needed, complete the one-time activation when prompted, then describe the app or feature you want to understand. REA handles the binary-analysis tools behind the scenes.
111
+ Review the setup plan, approve it if appropriate, complete Hopper's one-time activation when prompted, then describe the app or feature you want to understand.
103
112
 
104
113
  ### From Terminal — no installation
105
114
 
106
115
  ```bash
107
- npx -y rea-agents setup --yes
116
+ npx -y rea-agents setup
108
117
  npx -y rea-agents doctor
109
118
  npx -y rea-agents analyze /Applications/Notes.app
110
119
  ```
111
120
 
112
- If macOS or an installer asks for confirmation, complete the prompt and run the same command again. Restart a configured coding agent so it loads REA.
121
+ Review the setup plan before confirming it. Restart a configured coding agent so it loads REA.
113
122
 
114
123
  ### From Terminal — install the `rea` command
115
124
 
116
125
  ```bash
117
126
  npm install --global rea-agents
118
- rea setup --yes
127
+ rea setup
119
128
  rea doctor
120
129
  rea analyze /Applications/Notes.app
121
130
  ```
122
131
 
132
+ Update that global installation in place:
133
+
134
+ ```bash
135
+ rea upgrade
136
+ ```
137
+
138
+ REA checks npm for the latest release and verifies that the running package is
139
+ the global installation it will replace. Source, local, and `npx` copies report
140
+ the manual `npm install --global rea-agents@latest` command instead of updating
141
+ an unrelated global package.
142
+
123
143
  Choose either the no-install commands or the global installation. You do not need both.
124
144
 
125
145
  ### Requirements
126
146
 
127
147
  - macOS 12 or newer
128
148
  - Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux
129
- - Node.js 24.18.x with npm 11.16.x
149
+ - Node.js 22.19+ or 24.11+ (including newer releases)
150
+ - npm; REA does not require or install a particular npm version
130
151
 
131
- You do not need to choose or install binary-analysis tools yourself. REA setup handles them when needed. Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. REA can install the official macOS or Linux package, but you must approve installation and complete its one-time activation.
152
+ Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. Setup reuses an existing installation. If Hopper is missing, interactive setup proposes the official package and includes it in the confirmation plan. Unattended installation requires `rea setup --yes --install-hopper`.
132
153
 
133
154
  If something is not working, run:
134
155
 
@@ -140,7 +161,9 @@ npx -y rea-agents doctor
140
161
 
141
162
  ### Linux installation and troubleshooting
142
163
 
143
- On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
164
+ On macOS, approved setup downloads Hopper's official DMG, verifies it, and installs the app into `~/Applications` without Homebrew or administrator privileges. It opens Hopper once for activation; no manual drag-and-drop is required.
165
+
166
+ On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, approved setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
144
167
 
145
168
  The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere:
146
169
 
@@ -155,7 +178,7 @@ If doctor reports a missing analysis engine even though the file exists, inspect
155
178
  ldd /opt/hopper/bin/Hopper | grep 'not found'
156
179
  ```
157
180
 
158
- Install the missing distribution packages and rerun `rea setup --yes`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
181
+ Install the missing distribution packages and rerun `rea setup`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
159
182
 
160
183
  REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
161
184
 
@@ -166,7 +189,7 @@ rea uninstall
166
189
  rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
167
190
  ```
168
191
 
169
- Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
192
+ Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
170
193
 
171
194
  ### CLI or coding agent?
172
195
 
@@ -275,7 +298,7 @@ REA is growing into a toolkit for understanding software across static artifacts
275
298
  7. **More targets and platforms** — Windows-native providers and ConPTY verification, Linux parity, websites and APIs, mobile artifacts, firmware, document formats, and other software-defined systems.
276
299
  8. **Differential reconstruction** — compare artifacts, functions, bundles, protocols, UIs, and process captures; track residual unknowns; verify a reconstruction against observed behavior.
277
300
 
278
- Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
301
+ Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow. Once REA has multiple optional toolchains, setup can become capability-selective; the consent rules for that future work are recorded in the [installation roadmap](docs/roadmap.md).
279
302
 
280
303
  See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
281
304
 
@@ -343,6 +366,7 @@ Or install the `rea` command globally:
343
366
  ```bash
344
367
  npm install --global rea-agents
345
368
  rea --help
369
+ rea upgrade
346
370
  rea mcp
347
371
  ```
348
372
 
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { parseBinaryTarget } from "../domain/binaryTarget.js";
9
+ import { supportsNodeVersion } from "../domain/runtimeVersion.js";
9
10
  import { probeHomebrew } from "./homebrew.js";
10
11
  import { linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
11
12
  const execFileAsync = promisify(execFile);
@@ -18,9 +19,8 @@ const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
18
19
  */
19
20
  export const runDoctor = async (target, host = systemDoctorHost()) => {
20
21
  const checks = [];
21
- const nodeMajor = parseMajor(host.nodeVersion);
22
- checks.push(check("node", nodeMajor >= 24, host.nodeVersion, {
23
- remediation: "Install Node.js 24.18 or newer.",
22
+ checks.push(check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
23
+ remediation: "Install Node.js 22.19+ or 24.11+.",
24
24
  classification: "missing_dependency",
25
25
  }));
26
26
  const macosVersion = host.platform === "darwin" ? await host.macosVersion() : undefined;
@@ -50,7 +50,7 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
50
50
  break;
51
51
  }
52
52
  checks.push(check("hopper", hopperPath !== undefined, hopperPath, {
53
- remediation: "Run rea setup --yes to install Hopper, or set HOPPER_LAUNCHER_PATH.",
53
+ remediation: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
54
54
  classification: "missing_analysis_engine",
55
55
  }));
56
56
  if (target !== undefined)
@@ -1,6 +1,6 @@
1
1
  import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
2
- import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, } from "../domain/errors.js";
3
- import { parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
2
+ import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, projectAnalysisError, } from "../domain/errors.js";
3
+ import { addressDistance, parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
4
4
  import { err, ok } from "../domain/result.js";
5
5
  import { categorizeSwiftTypes, discoverObjcClasses, discoverObjcProtocols, discoverSwiftClasses, } from "../domain/symbolAnalysis.js";
6
6
  /**
@@ -89,20 +89,29 @@ export class EnhancedTools {
89
89
  return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
90
90
  }
91
91
  async #batchDecompile(addresses, signal) {
92
- if (addresses.length === 0)
93
- return ok({ error: "No addresses provided" });
94
- const entries = await Promise.all(addresses.map(async (address) => {
92
+ const items = await Promise.all(addresses.map(async (address) => {
95
93
  const result = await this.#call("procedure_pseudo_code", { procedure: address }, signal);
96
- return [
97
- address,
98
- result.ok
99
- ? result.value === null || result.value === ""
100
- ? "No output"
101
- : result.value
102
- : `Error: ${result.error.message}`,
103
- ];
94
+ if (!result.ok)
95
+ return {
96
+ address,
97
+ status: "error",
98
+ error: projectAnalysisError(result.error),
99
+ };
100
+ if (typeof result.value !== "string" || result.value.length === 0)
101
+ return {
102
+ address,
103
+ status: "error",
104
+ error: projectAnalysisError(new AnalysisOutputError("procedure_pseudo_code", "provider returned empty pseudocode")),
105
+ };
106
+ return { address, status: "ok", pseudocode: result.value };
104
107
  }));
105
- return ok(Object.fromEntries(entries));
108
+ const succeeded = items.filter(({ status }) => status === "ok").length;
109
+ return ok({
110
+ items,
111
+ total: items.length,
112
+ succeeded,
113
+ failed: items.length - succeeded,
114
+ });
106
115
  }
107
116
  async #callGraph(input, signal) {
108
117
  const relation = input.direction === "forward" ? "callees" : "callers";
@@ -124,7 +133,8 @@ export class EnhancedTools {
124
133
  if (!result.ok) {
125
134
  graph[level].push({
126
135
  address: current.address,
127
- error: result.error.message,
136
+ status: "error",
137
+ error: projectAnalysisError(result.error),
128
138
  });
129
139
  continue;
130
140
  }
@@ -132,12 +142,14 @@ export class EnhancedTools {
132
142
  if (!related.ok) {
133
143
  graph[level].push({
134
144
  address: current.address,
135
- error: related.error.message,
145
+ status: "error",
146
+ error: projectAnalysisError(related.error),
136
147
  });
137
148
  continue;
138
149
  }
139
150
  graph[level].push({
140
151
  address: current.address,
152
+ status: "ok",
141
153
  calls: [...related.value],
142
154
  });
143
155
  if (current.depth + 1 < input.depth) {
@@ -158,16 +170,24 @@ export class EnhancedTools {
158
170
  : procedures;
159
171
  }
160
172
  async #findXrefs(name, signal) {
161
- const resolved = await this.#call("address_name", { address: name }, signal);
162
- if (!resolved.ok)
163
- return resolved;
164
- const address = resolveAddress(resolved.value);
165
- if (address === undefined)
166
- return ok({ error: `Could not resolve name: ${name}` });
167
- const xrefs = await this.#call("xrefs", { address }, signal);
173
+ const names = await this.#allAddressed("list_names", signal);
174
+ if (!names.ok)
175
+ return names;
176
+ const resolved = names.value.find((entry) => entry.name === name);
177
+ if (resolved === undefined)
178
+ return ok({ status: "unresolved", name, reason: "name_not_found" });
179
+ const xrefs = await this.#call("xrefs", { address: resolved.address }, signal);
168
180
  if (!xrefs.ok)
169
181
  return xrefs;
170
- return ok(Array.isArray(xrefs.value) ? { xrefs: xrefs.value } : xrefs.value);
182
+ if (!Array.isArray(xrefs.value) ||
183
+ xrefs.value.some((xref) => typeof xref !== "string"))
184
+ return err(new AnalysisOutputError("xrefs", "provider returned an invalid address list"));
185
+ return ok({
186
+ status: "resolved",
187
+ name,
188
+ address: resolved.address,
189
+ xrefs: xrefs.value,
190
+ });
171
191
  }
172
192
  async #binaryOverview(input, signal) {
173
193
  const [segmentsResult, documentsResult, procedures, stringsResult] = await Promise.all([
@@ -346,15 +366,3 @@ export class EnhancedTools {
346
366
  const invalidInput = (name, cause) => Promise.resolve(err(new AnalysisInputError(name, {
347
367
  cause,
348
368
  })));
349
- const resolveAddress = (value) => {
350
- if (typeof value === "string" && value.length > 0)
351
- return value;
352
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
353
- return undefined;
354
- }
355
- const candidate = value.address ?? value.name;
356
- return typeof candidate === "string" && candidate.length > 0
357
- ? candidate
358
- : undefined;
359
- };
360
- const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
@@ -60,8 +60,9 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
60
60
  if (bytes > policy.maxBytes)
61
61
  return err(new EvidenceFileError("write", "too-large"));
62
62
  try {
63
- const canonicalParent = await realpath(dirname(resolve(path)));
64
- const destination = resolve(canonicalParent, basename(resolve(path)));
63
+ const requestedPath = resolve(path);
64
+ const canonicalParent = await realpath(dirname(requestedPath));
65
+ const destination = resolve(canonicalParent, basename(requestedPath));
65
66
  if (!(await isApproved(destination, policy.roots)))
66
67
  return err(new EvidenceFileError("write", "outside-root"));
67
68
  const existing = await lstat(destination).catch((cause) => {
@@ -80,7 +81,7 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
80
81
  mode: 0o600,
81
82
  fsync: true,
82
83
  });
83
- return ok({ path: destination, bytes });
84
+ return ok({ path: requestedPath, bytes });
84
85
  }
85
86
  catch (cause) {
86
87
  return err(new EvidenceFileError("write", "io", { cause }));
@@ -0,0 +1,175 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { access, mkdir, mkdtemp, readdir, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { z } from "zod";
8
+ const execFileAsync = promisify(execFile);
9
+ const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
10
+ const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
11
+ const MAX_PACKAGE_BYTES = 100_000_000;
12
+ const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
13
+ /** Download, verify, and install Hopper into the current user's Applications directory. */
14
+ export const installMacHopper = async (host = systemMacHopperInstallHost()) => {
15
+ let temporary;
16
+ let mounted = false;
17
+ try {
18
+ const metadata = await host.download(RELEASES_URL);
19
+ if (!metadata.ok)
20
+ return { status: "failed", reason: "release_metadata" };
21
+ const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
22
+ if (!parsed.success)
23
+ return { status: "failed", reason: "release_metadata" };
24
+ const release = parsed.data["OS X"];
25
+ if (!release.filename.startsWith(DOWNLOAD_PREFIX))
26
+ return { status: "failed", reason: "release_metadata" };
27
+ const archive = await host.download(release.filename);
28
+ if (!archive.ok)
29
+ return { status: "failed", reason: "download" };
30
+ if (!integrityMatches(archive.bytes, release))
31
+ return { status: "failed", reason: "integrity" };
32
+ const destination = host.destination();
33
+ if (await host.destinationExists(destination))
34
+ return { status: "failed", reason: "destination_exists" };
35
+ temporary = await host.createTemporaryDirectory();
36
+ const packagePath = join(temporary, "hopper.dmg");
37
+ const mountPath = join(temporary, "mounted");
38
+ await host.createMountDirectory(mountPath);
39
+ await host.writePackage(packagePath, archive.bytes);
40
+ if (!(await host.mount(packagePath, mountPath)))
41
+ return { status: "failed", reason: "mount" };
42
+ mounted = true;
43
+ const source = await host.appBundle(mountPath);
44
+ if (source === undefined || !(await host.validBundle(source)))
45
+ return { status: "failed", reason: "bundle" };
46
+ const stage = `${destination}.rea-stage`;
47
+ if (!(await host.installBundle(source, destination, stage)))
48
+ return { status: "failed", reason: "copy" };
49
+ const launcherPath = join(destination, "Contents/MacOS/hopper");
50
+ if (!(await host.launcherReady(launcherPath)))
51
+ return { status: "failed", reason: "launcher_missing" };
52
+ await host.openApplication(destination);
53
+ return { status: "installed", launcherPath };
54
+ }
55
+ catch {
56
+ return { status: "failed", reason: "download" };
57
+ }
58
+ finally {
59
+ if (temporary !== undefined) {
60
+ const mountPath = join(temporary, "mounted");
61
+ if (mounted)
62
+ await host.unmount(mountPath);
63
+ await host.cleanup(temporary);
64
+ }
65
+ }
66
+ };
67
+ const systemMacHopperInstallHost = () => ({
68
+ async download(url) {
69
+ const response = await fetch(url, {
70
+ headers: { accept: "application/json", "user-agent": "rea-installer" },
71
+ signal: AbortSignal.timeout(120_000),
72
+ });
73
+ const length = Number(response.headers.get("content-length") ?? "0");
74
+ if (length > MAX_PACKAGE_BYTES)
75
+ return { ok: false, bytes: new Uint8Array() };
76
+ const bytes = new Uint8Array(await response.arrayBuffer());
77
+ return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
78
+ },
79
+ createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
80
+ createMountDirectory: (path) => mkdir(path),
81
+ writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
82
+ async mount(packagePath, mountPath) {
83
+ return commandSucceeds("hdiutil", [
84
+ "attach",
85
+ "-readonly",
86
+ "-nobrowse",
87
+ "-mountpoint",
88
+ mountPath,
89
+ packagePath,
90
+ ]);
91
+ },
92
+ async unmount(mountPath) {
93
+ await commandSucceeds("hdiutil", ["detach", mountPath, "-force"]);
94
+ },
95
+ async appBundle(mountPath) {
96
+ const entries = await readdir(mountPath, { withFileTypes: true });
97
+ const app = entries.find((entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name));
98
+ return app === undefined ? undefined : join(mountPath, app.name);
99
+ },
100
+ async validBundle(appPath) {
101
+ if (!(await commandSucceeds("codesign", [
102
+ "--verify",
103
+ "--deep",
104
+ "--strict",
105
+ appPath,
106
+ ])))
107
+ return false;
108
+ try {
109
+ const identifier = (await execFileAsync("/usr/libexec/PlistBuddy", [
110
+ "-c",
111
+ "Print :CFBundleIdentifier",
112
+ join(appPath, "Contents/Info.plist"),
113
+ ])).stdout.trim();
114
+ return identifier === BUNDLE_IDENTIFIER;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ },
120
+ async destinationExists(path) {
121
+ try {
122
+ await access(path);
123
+ return true;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ },
129
+ async installBundle(source, destination, stage) {
130
+ try {
131
+ await mkdir(join(homedir(), "Applications"), { recursive: true });
132
+ await rm(stage, { recursive: true, force: true });
133
+ await execFileAsync("ditto", [source, stage]);
134
+ await rename(stage, destination);
135
+ return true;
136
+ }
137
+ catch {
138
+ await rm(stage, { recursive: true, force: true });
139
+ return false;
140
+ }
141
+ },
142
+ async launcherReady(path) {
143
+ try {
144
+ await access(path);
145
+ return true;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ },
151
+ async openApplication(path) {
152
+ await execFileAsync("open", [path]);
153
+ },
154
+ cleanup: (path) => rm(path, { recursive: true, force: true }),
155
+ destination: () => join(homedir(), "Applications/Hopper Disassembler.app"),
156
+ });
157
+ const parseJson = (bytes) => JSON.parse(new TextDecoder().decode(bytes));
158
+ const integrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
159
+ bytes.byteLength <= MAX_PACKAGE_BYTES &&
160
+ createHash("sha1").update(bytes).digest("hex") === release.file_hash;
161
+ const commandSucceeds = async (command, args) => {
162
+ try {
163
+ await execFileAsync(command, args);
164
+ return true;
165
+ }
166
+ catch {
167
+ return false;
168
+ }
169
+ };
170
+ const releaseSchema = z.object({
171
+ filename: z.string().url(),
172
+ file_length: z.string().regex(/^\d+$/u),
173
+ file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
174
+ });
175
+ const releasesSchema = z.object({ "OS X": releaseSchema });