diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 5316ef149..a0c3f0c95 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -26,6 +26,7 @@ export interface DoctorHost { readonly configuredHopperPath?: string; macosVersion(): Promise; readable(path: string): Promise; + executable(path: string): Promise; brewHopperPath(): Promise; manualHopperPaths(): Promise; } @@ -72,7 +73,7 @@ export const runDoctor = async ( ].filter((value): value is string => value !== undefined); let hopperPath: string | undefined; for (const candidate of new Set(candidates)) - if (await host.readable(candidate)) { + if (await host.executable(candidate)) { hopperPath = candidate; break; } @@ -124,6 +125,14 @@ export const systemDoctorHost = (): DoctorHost => ({ return false; } }, + async executable(path) { + try { + await access(path, constants.X_OK); + return true; + } catch { + return false; + } + }, async brewHopperPath() { return probeHomebrew(async (command) => { try { diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts index bda405f74..9788e7dca 100644 --- a/tests/doctor.test.ts +++ b/tests/doctor.test.ts @@ -6,6 +6,7 @@ const host = (overrides: Partial = {}): DoctorHost => ({ nodeVersion: "22.1.0", macosVersion: () => Promise.resolve("12.0"), readable: (path) => Promise.resolve(path.includes("Hopper")), + executable: (path) => Promise.resolve(path.includes("Hopper")), brewHopperPath: () => Promise.resolve(undefined), manualHopperPaths: () => Promise.resolve([]), ...overrides, @@ -29,7 +30,7 @@ describe("doctor", () => { undefined, host({ configuredHopperPath: "/manual/Hopper", - readable: (path) => Promise.resolve(path === "/manual/Hopper"), + executable: (path) => Promise.resolve(path === "/manual/Hopper"), }), ); expect(result.hopperPath).toBe("/manual/Hopper"); @@ -41,7 +42,7 @@ describe("doctor", () => { const result = await runDoctor( undefined, host({ - readable: (candidate) => Promise.resolve(candidate === path), + executable: (candidate) => Promise.resolve(candidate === path), brewHopperPath: () => Promise.resolve(path), }), ); @@ -53,10 +54,24 @@ describe("doctor", () => { const result = await runDoctor( undefined, host({ - readable: (candidate) => Promise.resolve(candidate === path), + executable: (candidate) => Promise.resolve(candidate === path), manualHopperPaths: () => Promise.resolve([path]), }), ); expect(result.hopperPath).toBe(path); }); + + it("rejects a readable Hopper launcher without execute permission", async () => { + const path = "/manual/Hopper"; + const result = await runDoctor( + undefined, + host({ + configuredHopperPath: path, + readable: () => Promise.resolve(true), + executable: () => Promise.resolve(false), + }), + ); + expect(result.hopperPath).toBeUndefined(); + expect(result.checks.find(({ name }) => name === "hopper")?.ok).toBe(false); + }); });