From a503376b2f9d9ba14d1602e58e0e93059e9d9181 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:28:25 +0000 Subject: [PATCH] fix(doctor): find the Homebrew Hopper cask without writing Homebrew caches `rea doctor`, documented as strictly read-only, ran `brew --prefix --cask hopper-disassembler` when Hopper was not in a standard location. Current Homebrew rejects that option combination, but any brew command naming a cask first fetches the cask API, so each doctor run wrote about 600 files (36 MB) into the user's Homebrew cache. Ask brew only for its Caskroom, which brew answers without loading the cask API, and read the application directory Homebrew recorded in the Hopper cask's install receipt. Fixes #1165 Co-Authored-By: Claude Opus 5.5 --- src/application/Doctor.systemHost.test.ts | 65 +++++++++++++++++++++++ src/application/Doctor.ts | 18 +++++-- src/application/homebrew.ts | 56 +++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/application/Doctor.systemHost.test.ts b/src/application/Doctor.systemHost.test.ts index c26cf15bd..7a160eae7 100644 --- a/src/application/Doctor.systemHost.test.ts +++ b/src/application/Doctor.systemHost.test.ts @@ -1,5 +1,9 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + import { expect, it } from "vitest"; +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { systemDoctorHost } from "./Doctor.js"; it("threads the selected host and environment into system diagnostics", async () => { @@ -41,3 +45,64 @@ it("threads the selected host and environment into system diagnostics", async () calls.every(({ environment: observed }) => observed === environment), ).toBe(true); }); + +it.each([ + [{ default: { appdir: "/Applications" } }, "/Applications"], + [ + { + default: { appdir: "/Applications" }, + env: { appdir: "/Env Apps" }, + explicit: { appdir: "~/Tools" }, + }, + "HOME/Tools", + ], + [ + { default: { appdir: "/Applications" }, env: { appdir: "/Env Apps" } }, + "/Env Apps", + ], +])( + "locates the Hopper cask from its Caskroom receipt without a cask command (%j)", + async (config, expectedAppdir) => { + const root = await createTestTempDirectory("rea-doctor-caskroom-"); + const caskroom = join(root, "Caskroom"); + const metadata = join(caskroom, "hopper-disassembler", ".metadata"); + await mkdir(metadata, { recursive: true }); + await writeFile(join(metadata, "config.json"), JSON.stringify(config)); + const calls: (readonly string[])[] = []; + const host = systemDoctorHost({ + platform: "darwin", + environment: { HOME: join(root, "home") }, + execFileOutput: async (_command, arguments_) => { + calls.push(arguments_); + return { stdout: `${caskroom}\n`, stderr: "" }; + }, + }); + + expect(await host.brewHopperPath()).toBe( + join( + expectedAppdir.replace("HOME", join(root, "home")), + "Hopper Disassembler.app/Contents/MacOS/hopper", + ), + ); + // A cask argument makes Homebrew fetch and cache its API data. + expect(calls).toEqual([["--caskroom"]]); + }, +); + +it("reports no Homebrew Hopper when the cask has no receipt", async () => { + const caskroom = await createTestTempDirectory("rea-doctor-caskroom-"); + const calls: (readonly string[])[] = []; + const host = systemDoctorHost({ + platform: "darwin", + environment: { HOME: caskroom }, + execFileOutput: async (_command, arguments_) => { + calls.push(arguments_); + return { stdout: `${caskroom}\n`, stderr: "" }; + }, + }); + + expect(await host.brewHopperPath()).toBeUndefined(); + expect( + calls.every((arguments_) => arguments_.join(" ") === "--caskroom"), + ).toBe(true); +}); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 9ff752f3a..7b523b428 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -7,7 +7,7 @@ import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentat import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import type { JsonValue } from "../domain/jsonValue.js"; -import { probeHomebrew } from "./homebrew.js"; +import { installedCaskAppdir, probeHomebrew } from "./homebrew.js"; import { linuxHopperBinarySupported, linuxSharedLibrariesAvailable, @@ -380,20 +380,30 @@ export const systemDoctorHost = ( supportedLinuxHopper: linuxHopperBinarySupported, async brewHopperPath() { return probeHomebrew(async (command) => { + let caskroom: string; try { - const prefix = ( + // Without a cask argument, `--caskroom` does not load Homebrew's + // cask API, so doctor writes no Homebrew cache files. + caskroom = ( await hostExecFileOutput( command, - ["--prefix", "--cask", "hopper-disassembler"], + ["--caskroom"], commandEnvironment, ) ).stdout.trim(); - return `${prefix}/Hopper Disassembler.app/Contents/MacOS/hopper`; } catch (cause: unknown) { // best-effort cleanup: optional Homebrew probing; absence means uninstalled. void cause; return undefined; } + const appdir = await installedCaskAppdir( + caskroom, + "hopper-disassembler", + homeDirectory, + ); + return appdir === undefined + ? undefined + : join(appdir, "Hopper Disassembler.app/Contents/MacOS/hopper"); }); }, manualHopperPaths: () => manualHopperPaths(homeDirectory), diff --git a/src/application/homebrew.ts b/src/application/homebrew.ts index 2b2070001..ae8710839 100644 --- a/src/application/homebrew.ts +++ b/src/application/homebrew.ts @@ -1,3 +1,8 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { z } from "zod"; + /** Standard executable names and locations used to discover Homebrew. */ const HOMEBREW_COMMANDS = [ "brew", @@ -19,3 +24,54 @@ export const probeHomebrew = async ( } return undefined; }; + +const caskDirectoriesSchema = z + .object({ appdir: z.string().min(1).optional() }) + .loose(); +const caskConfigSchema = z + .object({ + default: caskDirectoriesSchema.optional(), + env: caskDirectoriesSchema.optional(), + explicit: caskDirectoriesSchema.optional(), + }) + .loose(); + +/** + * Read the application directory Homebrew recorded when it installed a cask, + * in Homebrew's precedence: explicit option, then environment, then default. + * Reading the Caskroom avoids `brew` cask commands, which refresh Homebrew's + * API and bootsnap caches. + */ +export const installedCaskAppdir = async ( + caskroom: string, + token: string, + home: string, +): Promise => { + let text: string; + try { + text = await readFile( + join(caskroom, token, ".metadata", "config.json"), + "utf8", + ); + } catch (cause: unknown) { + // An absent or unreadable receipt means the cask is not installed here. + void cause; + return undefined; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (cause: unknown) { + // A malformed receipt does not identify an application directory. + void cause; + return undefined; + } + const config = caskConfigSchema.safeParse(parsed); + if (!config.success) return undefined; + const appdir = + config.data.explicit?.appdir ?? + config.data.env?.appdir ?? + config.data.default?.appdir; + if (appdir === undefined) return undefined; + return appdir.startsWith("~/") ? join(home, appdir.slice(2)) : appdir; +};