Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions src/daemon/launchd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,30 +161,38 @@ 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<string | null> {
// 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) {
const path = normalizeHackExecutablePath(raw);
if (!path) {
continue;
}
if (await pathExists(path)) {
if (await exists(path)) {
return path;
}
}
Expand Down
33 changes: 32 additions & 1 deletion tests/launchd.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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");
});
});
Loading