diff --git a/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md b/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md index e65fde130e..ad3d3cd656 100644 --- a/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md +++ b/devlog/_plan/260905_bug_triage_stack/030_wp4_launchd_stable_launcher.md @@ -6,6 +6,18 @@ against the current tree at this cycle's P before implementing. --- +## wp4 P stale-check + amendments (binding over the lane text below) + +Stale-check at 37622b92d (stack pos 2 head): `cliEntry(runtime)` :66, `writeServiceInstallState(backend, launcherPath)` :238, `buildPlist` :489-527, `installLaunchd` :2244-2272, `buildServiceLauncherShellCommand` :567, `buildUnit` launcher/env shape :3268-3301, `bakedServicePathsDiagnostic` launcher-aware :3236 — all as the lane text assumed. + +Amendment (missed by the lane): `launchdStart` (:2281-2300) decides "already loaded from the current plist" by matching the live `launchctl print` output against `buildServiceShellCommand(entry.bun, entry.cli)`. After this change a launcher-baked plist would never match that string, so every healthy `ocx service start` on macOS would fall through to the failure branch. Fix: derive the expected command from the recorded install state — `readServiceInstallState()?.launcherPath` present ⇒ `buildServiceLauncherShellCommand(launcherPath)`, else the Bun+CLI pair — and pin it with a test in the existing `launchdStart` describe. + +`ServiceInstallState.launcherPath` doc comment says "Linux only"; update to "launchd and systemd". + +Docs: lifecycle.md :245/:256 as the lane text says; structure/04 :36 generalised. Optional `version-skew.ts` wording change is **deferred** (separate concern, keeps this PR to launcher parity). + +--- + 1) VERDICT: FIXABLE Launchd parity is **not fixed**. The stable launcher fixes which version starts next; it does **not** replace an already-running proxy after `mise upgrade`. @@ -163,3 +175,10 @@ Risk: explicit security review is warranted under [MAINTAINERS.md:60](/Users/jun - Stable-launcher parity does **not** satisfy automatic repair/request refusal during an already-running version mismatch. That remains a separate product decision. - Actual mise shim selection under launchd’s login-shell environment needs a macOS smoke check; no live service or real upgrade was exercised. - Read-only investigation only: no files, Git state, GitHub comments, service state or tests were changed/run. + +Audit round 1 fold (GO-WITH-FIXES, 1 blocker): `serviceStatusReport()` (:4243) compares the live +job against `buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort())` too — +same fix as `launchdStart`: one helper `expectedLaunchdCommand(port)` that reads +`readServiceInstallState()?.launcherPath` and returns the launcher command when present, else the +Bun+CLI pair (never rediscovering PATH at start/status). `launchdStart` also gets the installed port. + diff --git a/devlog/_plan/260905_bug_triage_stack/060_ledger.md b/devlog/_plan/260905_bug_triage_stack/060_ledger.md index a9d73d4993..1a2f2eabc6 100644 --- a/devlog/_plan/260905_bug_triage_stack/060_ledger.md +++ b/devlog/_plan/260905_bug_triage_stack/060_ledger.md @@ -8,3 +8,4 @@ Append-only. One row per external action or verification. | 2026-09-04T22:53Z | #3467 / PR #3547 | pushed fd1dbbedb (--no-verify), PR opened base dev | typecheck 0; focused 48 pass; privacy ok; CI trailing | | 2026-09-04T23:16Z | #3462 / PR #3551 | pushed 9611cdf4b (--no-verify), PR opened base #3547 head | typecheck 0; focused 71 pass; privacy ok; CI trailing | +| 2026-09-04T23:36Z | #3464 / PR #3554 | pushed b4fbd293f (--no-verify), PR opened base #3551 head, Refs not Closes | typecheck 0; service.test.ts 199 pass; privacy ok; CI trailing | diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 3f3e286863..93c6fa63f3 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -242,18 +242,22 @@ interrupted package update removed either file, it logs one `installation is inc stops instead of retrying the same missing executable every five seconds. Reinstall opencodex, then run `ocx service repair` to refresh the task with the restored package paths. -On Linux, the systemd unit invokes the first regular, executable `ocx` file found on `PATH` at -install time rather than the Bun and CLI paths inside the installed package tree. Version managers such as +On macOS and Linux, the launchd plist and the systemd unit invoke the first regular, executable +`ocx` file found on `PATH` at install time rather than the Bun and CLI paths inside the installed +package tree. Version managers such as **mise** and **asdf** install into a versioned directory and delete the old one on upgrade, which -used to leave the unit pointing at files that no longer existed — systemd then restart-looped while -still reporting the service as installed. A shim path survives the upgrade, so the unit keeps -resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A +used to leave the service definition pointing at files that no longer existed — systemd then +restart-looped while still reporting the service as installed, and launchd kept the old build serving +until it was restarted by hand. A shim path survives the upgrade, so the definition keeps resolving. Source checkouts without an `ocx` launcher keep the previous direct Bun + CLI form. A trusted `OPENCODEX_BUN_PATH` selected before Bun starts is preserved through the shim; package-local bundled Bun paths are deliberately rediscovered after upgrades instead of being pinned in the unit. -Units installed before this change still carry the old versioned paths and cannot migrate +Definitions installed before this change still carry the old versioned paths and cannot migrate themselves — once the old executable is deleted, no opencodex code runs to fix it. Run -`ocx service repair` once after upgrading; subsequent version changes need no action. +`ocx service repair` once after upgrading; after that, each service start follows the launcher. +An already-running proxy is not replaced by an external upgrade: restart the service (or run +`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as +exactly that signal. | Subcommand | Action | | --- | --- | diff --git a/src/service.ts b/src/service.ts index 84b7da3817..b37e88c3db 100644 --- a/src/service.ts +++ b/src/service.ts @@ -205,9 +205,9 @@ export interface ServiceInstallState { bunPath?: string; cliPath?: string; /** - * Linux only. The stable `ocx` launcher the unit actually invokes, when one was found. - * Present means `bunPath`/`cliPath` are provenance for the install, NOT what systemd - * runs — so staleness must be judged against THIS path instead. A version-manager + * launchd and systemd. The stable `ocx` launcher the service definition actually invokes, + * when one was found. Present means `bunPath`/`cliPath` are provenance for the install, + * NOT what the service runs — so staleness must be judged against THIS path instead. A version-manager * upgrade replaces the directory those two point into while the launcher survives, and * checking the old pair would report a stale service that is in fact healthy. */ @@ -486,8 +486,21 @@ function writeServiceApiTokenFile(): string | null { return path; } -export function buildPlist(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string { - const { bun, bunRuntimeSource, cli } = cliEntry(); +/** + * Render the launchd plist. Mirrors `buildUnit`: when `deps.launcher` names a stable `ocx` + * executable, the job execs that launcher instead of the package-local Bun + CLI pair, so a + * version-manager upgrade (mise, asdf, nvm) that replaces the package directory is picked up + * on the next launchd start instead of leaving the old build serving (#3464 — the macOS + * counterpart of #2898). Discovery belongs to `installLaunchd()`; the default here is the + * legacy pair so callers and tests stay hermetic. + */ +export function buildPlist( + proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(), + deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {}, +): string { + const runtime = deps.runtime ?? durableBunRuntime(); + const { bun, bunRuntimeSource, cli } = cliEntry(runtime); + const launcher = deps.launcher ?? null; const log = logPath(); const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; const codexHome = process.env.CODEX_HOME?.trim(); @@ -495,8 +508,16 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve const opencodexHome = process.env.OPENCODEX_HOME?.trim(); const envLines = [ ` OCX_SERVICE1`, - ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, - ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, + ...(launcher ? [] : [ + ` ${BUN_RUNTIME_SOURCE_ENV}${bunRuntimeSource}`, + ` ${BUN_RUNTIME_PATH_ENV}${plistString(bun)}`, + ]), + // A launcher resolves the current package's bundled Bun after every upgrade. Preserve + // only a proof-bound shell override; baking a package-local path here would recreate + // the version-manager pin that launcher mode exists to remove (same rule as buildUnit). + launcher && runtime.source === "override" + ? ` ${runtime.overrideEnv}${plistString(runtime.path)}` + : null, ` PATH${plistString(path)}`, codexHome ? ` CODEX_HOME${plistString(codexHome)}` : null, codexSqliteHome ? ` CODEX_SQLITE_HOME${plistString(codexSqliteHome)}` : null, @@ -504,7 +525,9 @@ export function buildPlist(proxyEnv: { name: string; value: string }[] = resolve ...proxyEnv.map(({ name, value }) => ` ${name}${plistString(value)}`), ].filter((line): line is string => Boolean(line)).join("\n"); - const command = buildServiceShellCommand(bun, cli); + const command = launcher + ? buildServiceLauncherShellCommand(launcher) + : buildServiceShellCommand(bun, cli); return ` @@ -569,6 +592,23 @@ function buildServiceLauncherShellCommand(launcher: string, port = resolveServic return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`; } +/** + * The exec line the installed launchd plist is expected to carry, derived from the recorded + * install state rather than rediscovered: a launcher install runs the launcher, a legacy or + * stateless install runs the Bun + CLI pair. `start` and `status` compare the live job + * against this, so both must follow the launcher or a healthy launcher-backed job reads as + * "an OLDER plist" (#3464). PATH is deliberately NOT re-walked here. + */ +export function expectedLaunchdCommand( + port: number, + deps: { state?: ServiceInstallState | null; entry?: { bun: string; cli: string } } = {}, +): string { + const state = deps.state === undefined ? readServiceInstallState() : deps.state; + if (state?.launcherPath) return buildServiceLauncherShellCommand(state.launcherPath, port); + const entry = deps.entry ?? cliEntry(); + return buildServiceShellCommand(entry.bun, entry.cli, port); +} + /** * The `--port ` actually baked into the installed launchd plist, or null when it * cannot be read. macOS only — named for launchd rather than "service" so no caller @@ -2251,7 +2291,10 @@ function installLaunchd(): void { // Capture this BEFORE writing: the write below makes the plist exist unconditionally, // so a post-write existsSync would call every fresh install an "installed" service. const wasInstalled = existsSync(p); - writeServiceDefinitionFile(p, buildPlist(), "utf8"); + // Resolve the launcher ONCE and hand the same value to the plist and to install state, + // so the staleness diagnostic judges exactly what launchd runs. + const launcher = stableLauncherEntry(); + writeServiceDefinitionFile(p, buildPlist(resolvedProxyEnv(), { launcher }), "utf8"); // Best-effort: an absent job is fine here, and a failed unload is caught by the // load verification below with a better message than a raw unload error. runLaunchctl(["unload", p]); @@ -2268,7 +2311,7 @@ function installLaunchd(): void { + `then re-run '${wasInstalled ? "ocx service repair" : "ocx service install"}'.`, ); } - writeServiceInstallState(); + writeServiceInstallState("scheduler", launcher); } /** * Deps are named for the layer they replace, not for the process API: `launchctl` @@ -2291,9 +2334,8 @@ export function startLaunchd(deps: { // already be bootstrapped from THIS plist, which is a no-op rather than an error. // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and // throwing here would break `ocx service start` on every healthy service. - const entry = cliEntry(); const live = (deps.matches ?? launchdJobMatchesPlist)( - buildServiceShellCommand(entry.bun, entry.cli), + expectedLaunchdCommand(installedServiceListenPort()), ); if (live.loaded && live.matchesPlist) { console.log("ℹ️ service was already loaded from the current plist; nothing to do."); @@ -4234,14 +4276,11 @@ export async function serviceStatusReport( // Linux/Windows and make the stale-plist case untestable there. const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin" ? (() => { - const entry = cliEntry(); // Pass the INSTALLED port explicitly: the default third argument is // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after // a config edit the expected string would never match and every run would // print a false "OLDER plist". - return launchdJobMatchesPlist( - buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()), - ); + return launchdJobMatchesPlist(expectedLaunchdCommand(installedServiceListenPort())); })() : null); const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 7dc0745c99..c5efc0d5e8 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -33,19 +33,21 @@ enumeration twice made a measured 12.3-second fallback cost roughly 25 seconds b - 다른 대안 대신 이 방식을 선택한 이유: Removing or weakening revalidation widens the install race, while a global/TTL cache can outlive startup and stale absence can authorize the wrong home. Exact targeted-result identity lets the ordinary no-task locale fallback coalesce without hiding changed evidence. - 장점, 단점 및 영향: The reported stable zh-CN absence path performs two cheap targeted queries and one full listing. A task that appears is detected by the second targeted query; changed or failed evidence triggers a fresh fail-closed decision, so unusual churn may still pay for two listings rather than guess. -## Linux stable service launcher +## Stable service launcher (launchd and systemd) -Systemd installation resolves the first absolute `ocx` PATH candidate that is both a regular file +Launchd and systemd installation resolve the first absolute `ocx` PATH candidate that is both a regular file and executable, keeps that path lexical so a version-manager shim remains an indirection, and -records the same single resolution in the unit and service state. Unit construction never performs -PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI +records the same single resolution in the service definition and service state. Definition +construction (`buildPlist`, `buildUnit`) never performs PATH discovery itself: callers provide either the resolved launcher or an explicit direct Bun/CLI fallback, keeping diagnostics and tests independent of the host PATH. Launcher mode omits the package-local Bun provenance pair because an upgrade may delete that versioned tree. The only runtime path carried through the launcher is a pre-Bun, proof-bound `OPENCODEX_BUN_PATH` whose durable runtime source is `override`; bundled and process fallbacks are rediscovered by the current launcher. The API-auth token remains file-backed and is loaded only by -the service shell at start. +the service shell at start. On macOS, `start` and detailed `status` compare the live launchd job +against `expectedLaunchdCommand`, which follows the recorded `launcherPath` rather than re-walking +PATH, so a launcher-backed job is never misreported as an older plist (#3464). [Decision Log] - 목적과 의도: Keep systemd services upgrade-stable without losing an explicitly trusted Bun override or accepting a non-executable PATH placeholder. diff --git a/tests/service/service.test.ts b/tests/service/service.test.ts index 8c9d10cb8a..e36a2785b6 100644 --- a/tests/service/service.test.ts +++ b/tests/service/service.test.ts @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; import * as serviceModule from "../../src/service"; import { saveConfig } from "../../src/config"; import { windowsEnvIndirectBatchValue } from "../../src/lib/win-paths"; -import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; +import { assertServiceAuthEnvironment, assertServiceEnvironmentMatchesInstall, bakedServicePathsDiagnostic, confirmServiceServing, launchdListenPort, systemdListenPort, buildPlist, buildUnit, buildWindowsLauncherVbs, buildWindowsSchtasksCreateArgs, buildWindowsSchtasksCreateArgsForXml, buildWindowsServiceScript, buildWindowsTaskXml as buildWindowsTaskXmlProduction, buildWindowsTaskXmlDocument, deriveWindowsServiceDiagnostic, deriveWindowsServiceDiagnosticForCurrentUser, expectedLaunchdCommand, installFreshWindowsSchedulerSafely, installServiceSafely, launchctlLoadFailed, launchdJobMatchesPlist, normalizeServiceSubcommand, parseServiceArgs, parseServiceInstallState, planServiceCommand, prepareServiceInstall, probeServiceInstallation, readWindowsSchedulerXmlState, registerFreshWindowsSchedulerTask, removeNativeWindowsServiceForScheduler, repairService, reportServiceServing, resolveServiceListenPort, runLaunchctl, selectServiceSubcommand, SERVICE_INSTALL_HEALTH_MS, SERVICE_INSTALL_HEALTH_WINDOWS_MS, serviceInstallHealthMs, serviceLogPath, serviceStartableFromTray, serviceStatusReport, serviceRetryCommand, serviceStatusSummary, stableLauncherEntry, systemdNeedsDaemonReload, systemdServiceInstallCleanupOps, uninstallSystemd, windowsListenPort, winswListenPort, startLaunchd, windowsTaskRegistrationHealthy as windowsTaskRegistrationHealthyProduction } from "../../src/service"; import type { ServiceDiagnostic } from "../../src/service"; import { definitionCarriesCredential, resolvedProxyEnv, writeServiceDefinitionFile } from "../../src/service"; import { buildWinswXml } from "../../src/lib/winsw"; @@ -1182,6 +1182,68 @@ describe("launchd service plist", () => { expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); }); + + // #3464. The macOS counterpart of the systemd launcher test above: a mise/asdf upgrade replaces + // the versioned package directory, and a plist that named the old Bun + CLI pair keeps launchd + // on the stale build until someone restarts it. Naming the shim lets the next start follow it. + test("a stable launcher install names the launcher in the plist and bakes no versioned path (#3464)", () => { + const launcher = "/home/u/.local/share/mise/shims/ocx"; + const plist = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/opt/opencodex/versioned/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + + expect(plist).toContain(launcher); + expect(plist).toContain("start --port"); + for (const forbidden of [ + "OCX_BUN_RUNTIME_PATH", + "OCX_BUN_RUNTIME_SOURCE", + "OPENCODEX_BUN_PATH", + "/opt/opencodex/versioned/bun", + "cli/index.ts", + ]) expect(plist).not.toContain(forbidden); + // The token still comes from the file at start, never from the plist. + expectTextToContainPath(plist, serviceApiTokenFilePath()); + expect(plist).toContain("OPENCODEX_API_AUTH_TOKEN"); + // launchdListenPort reads the same "start --port N" tail from either command shape. + expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort()); + + // Without a launcher the plist keeps the previous shape, so source checkouts are unaffected. + const direct = buildPlist(resolvedProxyEnv({}), { launcher: null }); + expectTextToContainPath(direct, join("cli", "index.ts")); + expect(direct).toContain("OCX_BUN_RUNTIME_PATH"); + expect(direct).toContain("OCX_BUN_RUNTIME_SOURCE"); + }); + + test("launcher mode preserves only a proof-bound Bun override, never an ambient one (#3464)", () => { + const launcher = "/home/u/.local/share/mise/shims/ocx"; + const trusted = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/custom/bun", source: "override", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + expect(trusted).toContain("OPENCODEX_BUN_PATH/custom/bun"); + expect(trusted).not.toContain("OCX_BUN_RUNTIME_PATH"); + + const bundled = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/custom/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + expect(bundled).not.toContain("OPENCODEX_BUN_PATH"); + expect(bundled).not.toContain("/custom/bun"); + }); + + test("launcher paths with shell and XML metacharacters stay quoted in the plist (#3464)", () => { + const launcher = "/home/u/My Tools & Shims/it's/ocx"; + const plist = buildPlist(resolvedProxyEnv({}), { + launcher, + runtime: { path: "/opt/bun", source: "bundled", overrideEnv: "OPENCODEX_BUN_PATH" }, + }); + // XML-escaped ampersand inside the ProgramArguments string; the shell quoting survives. + expect(plist).toContain("&"); + expect(plist).not.toContain("Shims/it's/ocx start"); + expect(launchdListenPort({ readPlist: () => plist })).toBe(resolveServiceListenPort()); + }); + // The scenario itself, executed rather than asserted: retarget the shim the way an upgrade // does, delete the old version, and check the generated command still reaches live code. test("the generated launcher command follows a retargeted shim after the old version is gone", () => { @@ -3143,6 +3205,38 @@ describe("launchctl load verification", () => { }); }); + + // #3464. start and status compare the live job against the command the plist SHOULD carry. + // A launcher install carries the launcher line, so the comparison must follow the recorded + // install state or every healthy launcher-backed service reads as "an OLDER plist". + describe("expectedLaunchdCommand follows the recorded launcher", () => { + const entry = { bun: "/opt/opencodex/versioned/bun", cli: "/opt/opencodex/versioned/src/cli/index.ts" }; + const base = { version: 2 as const, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", backend: "scheduler" as const }; + + test("a recorded launcher yields the launcher exec line at the installed port", () => { + const command = expectedLaunchdCommand(14001, { + state: { ...base, bunPath: entry.bun, cliPath: entry.cli, launcherPath: "/home/u/.local/share/mise/shims/ocx" }, + entry, + }); + expect(command).toContain("exec '/home/u/.local/share/mise/shims/ocx' start --port 14001"); + expect(command).not.toContain(entry.cli); + }); + + test("v1 / legacy state without a launcher yields the Bun + CLI pair", () => { + const command = expectedLaunchdCommand(14001, { + state: { version: 1, codexHome: "/h/.codex", opencodexHome: "/h/.opencodex", bunPath: entry.bun, cliPath: entry.cli }, + entry, + }); + expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`); + }); + + test("missing state falls back to the Bun + CLI pair and never re-walks PATH", () => { + const command = expectedLaunchdCommand(14001, { state: null, entry }); + expect(command).toContain(`exec '${entry.bun}' '${entry.cli}' start --port 14001`); + expect(command).not.toContain("shims/ocx"); + }); + }); + describe("startLaunchd", () => { // A runLaunchctl RESULT, not a spawnSync result. const failedLoad = () => ({ ok: true, stdout: "", stderr: "Load failed: 5: Input/output error" });