Skip to content

Commit e3be66e

Browse files
authored
fix(daemon): prefer active CLI for launchd (#67)
## Summary - prefer the resolved active Hack CLI when writing or repairing the launchd service - retain the global Hack binary as a fallback when no active executable is available - cover mixed Homebrew plus stale local-development shim installs ## Verification - bun test tests/launchd.test.ts - bun run check - bun run typecheck - bun run build - bun test: 907 pass, 5 skip; one process-table test sandbox-blocked - bun test tests/daemon-command.test.ts outside the restricted process sandbox: 1 pass
1 parent dbe1ea2 commit e3be66e

2 files changed

Lines changed: 49 additions & 10 deletions

File tree

src/daemon/launchd.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -161,30 +161,38 @@ export async function installLaunchdService({
161161
* launchd should execute `hack` directly; writing `bun` here can break daemon
162162
* startup when Bun is unavailable in non-interactive PATH contexts.
163163
*/
164-
async function resolveLaunchdHackBinPath(opts: {
164+
export async function resolveLaunchdHackBinPath(opts: {
165165
readonly invocation: {
166166
readonly bin: string;
167167
readonly args: readonly string[];
168168
};
169+
readonly findExecutable?: typeof findExecutableInPath;
170+
readonly exists?: typeof pathExists;
171+
readonly globalHackBinPath?: string;
172+
readonly argvPath?: string | null;
169173
}): Promise<string | null> {
170-
// Stable locations first: versioned paths (homebrew Cellar) rot on
171-
// upgrade, and argv paths inside a compiled Bun binary report the
172-
// virtual /$bunfs mount, which only exists inside that process —
173-
// launchd exec'ing it fails forever with status 78.
174+
const findExecutable = opts.findExecutable ?? findExecutableInPath;
175+
const exists = opts.exists ?? pathExists;
176+
177+
// Prefer the executable selected for this invocation. A valid
178+
// ~/.hack/bin/hack may be an older local-development shim alongside a
179+
// newer Homebrew install; choosing that fallback first makes every daemon
180+
// restart downgrade hackd. Keep stable PATH/invocation locations ahead of
181+
// the global fallback, while still rejecting compiled Bun virtual paths.
174182
const candidates = [
175-
resolve(resolveGlobalHackDir(), "bin", "hack"),
176-
await findExecutableInPath("hack"),
177183
opts.invocation.args[0] ?? null,
178184
opts.invocation.bin,
179-
process.argv[1] ?? null,
185+
await findExecutable("hack"),
186+
opts.globalHackBinPath ?? resolve(resolveGlobalHackDir(), "bin", "hack"),
187+
opts.argvPath === undefined ? (process.argv[1] ?? null) : opts.argvPath,
180188
];
181189

182190
for (const raw of candidates) {
183191
const path = normalizeHackExecutablePath(raw);
184192
if (!path) {
185193
continue;
186194
}
187-
if (await pathExists(path)) {
195+
if (await exists(path)) {
188196
return path;
189197
}
190198
}

tests/launchd.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, test } from "bun:test";
22
import { DAEMON_LAUNCHD_LABEL } from "../src/constants.ts";
3-
import { renderLaunchdPlist } from "../src/daemon/launchd.ts";
3+
import {
4+
renderLaunchdPlist,
5+
resolveLaunchdHackBinPath,
6+
} from "../src/daemon/launchd.ts";
47

58
describe("launchd plist generation", () => {
69
test("renders plist with RunAtLoad false", () => {
@@ -118,3 +121,31 @@ describe("daemon constants", () => {
118121
expect(DAEMON_LAUNCHD_LABEL).toMatch(/^[a-z]+\.[a-z]+\.[a-z]+$/);
119122
});
120123
});
124+
125+
describe("launchd executable resolution", () => {
126+
test("prefers the active CLI over an older global development shim", async () => {
127+
const resolved = await resolveLaunchdHackBinPath({
128+
invocation: { bin: "/opt/homebrew/bin/hack", args: [] },
129+
findExecutable: () => "/opt/homebrew/bin/hack",
130+
exists: async (path) =>
131+
path === "/opt/homebrew/bin/hack" ||
132+
path === "/Users/testuser/.hack/bin/hack",
133+
globalHackBinPath: "/Users/testuser/.hack/bin/hack",
134+
argvPath: null,
135+
});
136+
137+
expect(resolved).toBe("/opt/homebrew/bin/hack");
138+
});
139+
140+
test("retains the global binary as a fallback", async () => {
141+
const resolved = await resolveLaunchdHackBinPath({
142+
invocation: { bin: "bun", args: ["/$bunfs/root/hack"] },
143+
findExecutable: () => null,
144+
exists: async (path) => path === "/Users/testuser/.hack/bin/hack",
145+
globalHackBinPath: "/Users/testuser/.hack/bin/hack",
146+
argvPath: "/$bunfs/root/hack",
147+
});
148+
149+
expect(resolved).toBe("/Users/testuser/.hack/bin/hack");
150+
});
151+
});

0 commit comments

Comments
 (0)