Merge pull request #1182 from morluto/fix/doctor-hopper-discovery-readonly

fix(doctor): find the Homebrew Hopper cask without writing Homebrew caches
This commit is contained in:
Kaoru0822 2026-10-08 21:13:00 -07:00 committed by GitHub
commit 98b07d07dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 135 additions and 4 deletions

View File

@ -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);
});

View File

@ -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),

View File

@ -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 <T>(
}
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<string | undefined> => {
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;
};