diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 878e7c2c..0682ddc6 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -161,22 +161,30 @@ export async function installLaunchdService({ * launchd should execute `hack` directly; writing `bun` here can break daemon * startup when Bun is unavailable in non-interactive PATH contexts. */ -async function resolveLaunchdHackBinPath(opts: { +export async function resolveLaunchdHackBinPath(opts: { readonly invocation: { readonly bin: string; readonly args: readonly string[]; }; + readonly findExecutable?: typeof findExecutableInPath; + readonly exists?: typeof pathExists; + readonly globalHackBinPath?: string; + readonly argvPath?: string | null; }): Promise { - // Stable locations first: versioned paths (homebrew Cellar) rot on - // upgrade, and argv paths inside a compiled Bun binary report the - // virtual /$bunfs mount, which only exists inside that process — - // launchd exec'ing it fails forever with status 78. + const findExecutable = opts.findExecutable ?? findExecutableInPath; + const exists = opts.exists ?? pathExists; + + // Prefer the executable selected for this invocation. A valid + // ~/.hack/bin/hack may be an older local-development shim alongside a + // newer Homebrew install; choosing that fallback first makes every daemon + // restart downgrade hackd. Keep stable PATH/invocation locations ahead of + // the global fallback, while still rejecting compiled Bun virtual paths. const candidates = [ - resolve(resolveGlobalHackDir(), "bin", "hack"), - await findExecutableInPath("hack"), opts.invocation.args[0] ?? null, opts.invocation.bin, - process.argv[1] ?? null, + await findExecutable("hack"), + opts.globalHackBinPath ?? resolve(resolveGlobalHackDir(), "bin", "hack"), + opts.argvPath === undefined ? (process.argv[1] ?? null) : opts.argvPath, ]; for (const raw of candidates) { @@ -184,7 +192,7 @@ async function resolveLaunchdHackBinPath(opts: { if (!path) { continue; } - if (await pathExists(path)) { + if (await exists(path)) { return path; } } diff --git a/tests/launchd.test.ts b/tests/launchd.test.ts index dcfd9be7..4794fae8 100644 --- a/tests/launchd.test.ts +++ b/tests/launchd.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import { DAEMON_LAUNCHD_LABEL } from "../src/constants.ts"; -import { renderLaunchdPlist } from "../src/daemon/launchd.ts"; +import { + renderLaunchdPlist, + resolveLaunchdHackBinPath, +} from "../src/daemon/launchd.ts"; describe("launchd plist generation", () => { test("renders plist with RunAtLoad false", () => { @@ -118,3 +121,31 @@ describe("daemon constants", () => { expect(DAEMON_LAUNCHD_LABEL).toMatch(/^[a-z]+\.[a-z]+\.[a-z]+$/); }); }); + +describe("launchd executable resolution", () => { + test("prefers the active CLI over an older global development shim", async () => { + const resolved = await resolveLaunchdHackBinPath({ + invocation: { bin: "/opt/homebrew/bin/hack", args: [] }, + findExecutable: () => "/opt/homebrew/bin/hack", + exists: async (path) => + path === "/opt/homebrew/bin/hack" || + path === "/Users/testuser/.hack/bin/hack", + globalHackBinPath: "/Users/testuser/.hack/bin/hack", + argvPath: null, + }); + + expect(resolved).toBe("/opt/homebrew/bin/hack"); + }); + + test("retains the global binary as a fallback", async () => { + const resolved = await resolveLaunchdHackBinPath({ + invocation: { bin: "bun", args: ["/$bunfs/root/hack"] }, + findExecutable: () => null, + exists: async (path) => path === "/Users/testuser/.hack/bin/hack", + globalHackBinPath: "/Users/testuser/.hack/bin/hack", + argvPath: "/$bunfs/root/hack", + }); + + expect(resolved).toBe("/Users/testuser/.hack/bin/hack"); + }); +});