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; +};