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:
commit
98b07d07dd
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue