fix(hopper): skip non-file launcher candidates

Require automatically discovered Hopper launchers to be executable regular
files so POSIX directory search permission cannot hide a later valid
application bundle. Preserve file symlinks, explicit caller configuration,
and the shared candidate ordering.

Cover canonical and renamed user bundles through the production filesystem
predicate; respect existing canonical or renamed system installations in
the isolated fixtures. Clarify launcher eligibility in the installation guide.

Follow-up to #3085 and #3054 after their shared path discovery landed on main.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 00:09:42 +08:00
1 parent 1438422dfe
commit 044fb9127a
3 files changed
+38 -2

No files matched your search

+2
View File
@@ -585,6 +585,8 @@ system application, the canonical application installed by `rea setup` under
diagnostic; to use a Homebrew-installed launcher for CLI/MCP startup, set
`HOPPER_LAUNCHER_PATH` to the path reported by doctor. REA does not run Homebrew
while loading runtime configuration.
Automatic discovery accepts executable regular files, including file symlinks,
and skips directories.
On Linux, it prefers executable `/opt/hopper/bin/Hopper`, then executable
`~/.local/share/rea/hopper/bin/Hopper`, followed by `/opt/hopper-v4/bin/hopper`
and `/usr/bin/hopper`; if none exists, the former remains the diagnostic
+2 -2
View File
@@ -1,4 +1,4 @@
import { accessSync, constants } from "node:fs";
import { accessSync, constants, statSync } from "node:fs";
import type { ConfigurationError } from "../domain/configurationErrors.js";
import { ok, type Result } from "../domain/result.js";
@@ -16,7 +16,7 @@ import {
const executableAvailable = (path: string): boolean => {
try {
accessSync(path, constants.X_OK);
return true;
return statSync(path).isFile();
} catch {
return false;
}
@@ -0,0 +1,34 @@
import { accessSync, constants, statSync } from "node:fs";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { expect, it } from "vitest";
import { defaultHopperLauncherPath } from "../../../src/config/parseConfig.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// A real system installation legitimately precedes this isolated user fixture.
const installedSystemHopper = (home: string): boolean => {
try {
const launcher = defaultHopperLauncherPath("darwin", home);
accessSync(launcher, constants.X_OK);
return statSync(launcher).isFile();
} catch {
return false;
}
};
it.for(["Hopper Disassembler.app", "Hopper A.app"])(
"skips a directory launcher in %s and selects a real file",
async (name, context) => {
const home = await createTestTempDirectory("rea-hopper-selection-");
if (installedSystemHopper(home)) context.skip();
const invalid = join(home, "Applications", name, "Contents/MacOS/hopper");
const valid = join(home, "Applications/Hopper Z.app/Contents/MacOS/hopper");
await mkdir(invalid, { recursive: true });
await mkdir(dirname(valid), { recursive: true });
await writeFile(valid, "#!/bin/sh\nexit 0\n", { mode: 0o700 });
expect(defaultHopperLauncherPath("darwin", home)).toBe(valid);
},
);