fix(setup): persist detected Hopper launcher

This commit is contained in:
morluto 2026-07-12 09:12:13 +08:00
parent 88f5edc236
commit b2866e4706
3 changed files with 43 additions and 3 deletions

View File

@ -48,7 +48,10 @@ export interface SetupHost {
hopperPath(): Promise<string | undefined>;
installHopper(): Promise<boolean>;
detectedClients(): Promise<readonly SetupClient[]>;
configureClient(client: SetupClient): Promise<ClientConfigurationResult>;
configureClient(
client: SetupClient,
hopperPath: string,
): Promise<ClientConfigurationResult>;
installSkill(): Promise<"installed" | "unchanged" | "failed">;
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
}
@ -97,16 +100,22 @@ export const runSetup = async (
);
actions.push("installed_homebrew");
}
if ((await host.hopperPath()) === undefined) {
let hopperPath = await host.hopperPath();
if (hopperPath === undefined) {
if (!yes) return fail("Re-run with --yes to install Hopper.");
if (!(await host.installHopper()))
return fail(
"Hopper installation was interrupted; resolve the system prompt and re-run setup.",
);
actions.push("installed_hopper");
hopperPath = await host.hopperPath();
if (hopperPath === undefined)
return fail(
"Hopper installation completed but its launcher was not found.",
);
}
for (const client of await host.detectedClients()) {
const result = await host.configureClient(client);
const result = await host.configureClient(client, hopperPath);
clients[client.name] = result;
if (result.status === "failed")
return fail(
@ -188,6 +197,7 @@ const detectJsonClients = async (
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
export const configureJsonClient = async (
client: SetupClient,
hopperPath?: string,
): Promise<ClientConfigurationResult> => {
let document: Record<string, unknown> = {};
let original: string | undefined;
@ -206,6 +216,9 @@ export const configureJsonClient = async (
const desired = {
command: "npx",
args: ["-y", PRODUCT_IDENTITY.packageName, "mcp"],
...(hopperPath === undefined
? {}
: { env: { HOPPER_LAUNCHER_PATH: hopperPath } }),
};
if (
JSON.stringify(servers[PRODUCT_IDENTITY.mcpServerKey]) ===

View File

@ -22,6 +22,7 @@ class FakeSetupHost implements SetupHost {
homebrewInstalls = 0;
hopperInstalls = 0;
configurations = 0;
configuredHopperPaths: string[] = [];
macosVersion = (): Promise<string | undefined> =>
Promise.resolve(this.version);
@ -41,8 +42,10 @@ class FakeSetupHost implements SetupHost {
Promise.resolve(this.clients);
configureClient = (
client: SetupClient,
hopperPath: string,
): Promise<ClientConfigurationResult> => {
this.configurations += 1;
this.configuredHopperPaths.push(hopperPath);
return Promise.resolve(
this.clientResults.get(client.name) ?? {
status: "configured",
@ -99,6 +102,7 @@ describe("setup workflow", () => {
host.hopper = "/Applications/Manual Hopper";
expect((await runSetup(true, host)).actions).toEqual(["installed_skill"]);
expect(host.hopperInstalls).toBe(0);
expect(host.configuredHopperPaths).toEqual([]);
});
it.each(["homebrew", "hopper"] as const)(
@ -127,6 +131,7 @@ describe("setup workflow", () => {
const result = await runSetup(true, host);
expect(Object.keys(result.clients)).toEqual(["claude", "cursor"]);
expect(host.configurations).toBe(2);
expect(host.configuredHopperPaths).toEqual(["/Hopper", "/Hopper"]);
});
it.each(["backup", "write", "readback"] as const)(

View File

@ -49,6 +49,28 @@ describe("JSON client configuration transaction", () => {
).rejects.toThrow();
});
it("persists a custom Hopper launcher and remains idempotent", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");
const hopperPath = "/Applications/Hopper v6.app/Contents/MacOS/hopper";
const client = { name: "cursor", configPath };
expect(await configureJsonClient(client, hopperPath)).toMatchObject({
status: "configured",
});
expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({
mcpServers: {
rea: {
command: "npx",
args: ["-y", "@morluto/rea", "mcp"],
env: { HOPPER_LAUNCHER_PATH: hopperPath },
},
},
});
expect(await configureJsonClient(client, hopperPath)).toEqual({
status: "unchanged",
});
});
it("refuses malformed existing JSON without overwriting it", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-setup-"));
const configPath = join(directory, "mcp.json");