From 7db1cbe178a9f5711a02762a79a9ea5885982cb9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 08:33:28 +0000 Subject: [PATCH] fix: improve daemon node selection --- src/cli/daemon-cli.ts | 11 ++- src/commands/configure.ts | 11 ++- src/commands/doctor-gateway-services.ts | 10 +- src/commands/doctor.ts | 16 ++- src/commands/onboard-non-interactive.ts | 14 ++- src/daemon/runtime-paths.test.ts | 123 ++++++++++++++++++++++++ src/daemon/runtime-paths.ts | 75 ++++++++++++++- src/infra/runtime-guard.ts | 4 + src/wizard/onboarding.ts | 14 ++- 9 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 src/daemon/runtime-paths.test.ts diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 175c383c6..1a2b7fac9 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -32,7 +32,11 @@ import { import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; +import { + renderSystemNodeWarning, + resolvePreferredNodePath, + resolveSystemNodeInfo, +} from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import type { ServiceConfigAudit } from "../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../daemon/service-audit.js"; @@ -923,6 +927,11 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { runtime: runtimeRaw, nodePath, }); + if (runtimeRaw === "node") { + const systemNode = await resolveSystemNodeInfo({ env: process.env }); + const warning = renderSystemNodeWarning(systemNode, programArguments[0]); + if (warning) defaultRuntime.log(warning); + } const environment = buildServiceEnvironment({ env: process.env, port, diff --git a/src/commands/configure.ts b/src/commands/configure.ts index a703f9e2a..82ebb1296 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -17,7 +17,11 @@ import { } from "../config/config.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; +import { + renderSystemNodeWarning, + resolvePreferredNodePath, + resolveSystemNodeInfo, +} from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; @@ -448,6 +452,11 @@ async function maybeInstallDaemon(params: { runtime: daemonRuntime, nodePath, }); + if (daemonRuntime === "node") { + const systemNode = await resolveSystemNodeInfo({ env: process.env }); + const warning = renderSystemNodeWarning(systemNode, programArguments[0]); + if (warning) note(warning, "Gateway runtime"); + } const environment = buildServiceEnvironment({ env: process.env, port: params.port, diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index b83c2067f..06f14111e 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -13,8 +13,9 @@ import { } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { + renderSystemNodeWarning, resolvePreferredNodePath, - resolveSystemNodePath, + resolveSystemNodeInfo, } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { @@ -183,10 +184,13 @@ export async function maybeRepairGatewayServiceConfig( command, }); const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues); - const systemNodePath = needsNodeRuntime - ? await resolveSystemNodePath(process.env) + const systemNodeInfo = needsNodeRuntime + ? await resolveSystemNodeInfo({ env: process.env }) : null; + const systemNodePath = systemNodeInfo?.supported ? systemNodeInfo.path : null; if (needsNodeRuntime && !systemNodePath) { + const warning = renderSystemNodeWarning(systemNodeInfo); + if (warning) note(warning, "Gateway runtime"); note( "System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.", "Gateway runtime", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 5b00ab973..d9fea75ed 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -25,7 +25,11 @@ import { import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; +import { + renderSystemNodeWarning, + resolvePreferredNodePath, + resolveSystemNodeInfo, +} from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; @@ -621,6 +625,16 @@ export async function doctorCommand( runtime: daemonRuntime, nodePath, }); + if (daemonRuntime === "node") { + const systemNode = await resolveSystemNodeInfo({ + env: process.env, + }); + const warning = renderSystemNodeWarning( + systemNode, + programArguments[0], + ); + if (warning) note(warning, "Gateway runtime"); + } const environment = buildServiceEnvironment({ env: process.env, port, diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index c197a8560..0358dbeb7 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -16,7 +16,11 @@ import { } from "../config/config.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; +import { + renderSystemNodeWarning, + resolvePreferredNodePath, + resolveSystemNodeInfo, +} from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; @@ -535,6 +539,14 @@ export async function runNonInteractiveOnboarding( runtime: daemonRuntimeRaw, nodePath, }); + if (daemonRuntimeRaw === "node") { + const systemNode = await resolveSystemNodeInfo({ env: process.env }); + const warning = renderSystemNodeWarning( + systemNode, + programArguments[0], + ); + if (warning) runtime.log(warning); + } const environment = buildServiceEnvironment({ env: process.env, port, diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts new file mode 100644 index 000000000..1edae30c6 --- /dev/null +++ b/src/daemon/runtime-paths.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const fsMocks = vi.hoisted(() => ({ + access: vi.fn(), +})); + +vi.mock("node:fs/promises", () => ({ + default: { access: fsMocks.access }, + access: fsMocks.access, +})); + +import { + renderSystemNodeWarning, + resolvePreferredNodePath, + resolveSystemNodeInfo, +} from "./runtime-paths.js"; + +afterEach(() => { + vi.resetAllMocks(); +}); + +describe("resolvePreferredNodePath", () => { + const darwinNode = "/opt/homebrew/bin/node"; + + it("uses system node when it meets the minimum version", async () => { + fsMocks.access.mockImplementation(async (target: string) => { + if (target === darwinNode) return; + throw new Error("missing"); + }); + + const execFile = vi + .fn() + .mockResolvedValue({ stdout: "22.1.0\n", stderr: "" }); + + const result = await resolvePreferredNodePath({ + env: {}, + runtime: "node", + platform: "darwin", + execFile, + }); + + expect(result).toBe(darwinNode); + expect(execFile).toHaveBeenCalledTimes(1); + }); + + it("skips system node when it is too old", async () => { + fsMocks.access.mockImplementation(async (target: string) => { + if (target === darwinNode) return; + throw new Error("missing"); + }); + + const execFile = vi + .fn() + .mockResolvedValue({ stdout: "18.19.0\n", stderr: "" }); + + const result = await resolvePreferredNodePath({ + env: {}, + runtime: "node", + platform: "darwin", + execFile, + }); + + expect(result).toBeUndefined(); + expect(execFile).toHaveBeenCalledTimes(1); + }); + + it("returns undefined when no system node is found", async () => { + fsMocks.access.mockRejectedValue(new Error("missing")); + + const execFile = vi.fn(); + + const result = await resolvePreferredNodePath({ + env: {}, + runtime: "node", + platform: "darwin", + execFile, + }); + + expect(result).toBeUndefined(); + expect(execFile).not.toHaveBeenCalled(); + }); +}); + +describe("resolveSystemNodeInfo", () => { + const darwinNode = "/opt/homebrew/bin/node"; + + it("returns supported info when version is new enough", async () => { + fsMocks.access.mockImplementation(async (target: string) => { + if (target === darwinNode) return; + throw new Error("missing"); + }); + + const execFile = vi + .fn() + .mockResolvedValue({ stdout: "22.0.0\n", stderr: "" }); + + const result = await resolveSystemNodeInfo({ + env: {}, + platform: "darwin", + execFile, + }); + + expect(result).toEqual({ + path: darwinNode, + version: "22.0.0", + supported: true, + }); + }); + + it("renders a warning when system node is too old", () => { + const warning = renderSystemNodeWarning( + { + path: darwinNode, + version: "18.19.0", + supported: false, + }, + "/Users/me/.fnm/node-22/bin/node", + ); + + expect(warning).toContain("below the required Node 22+"); + expect(warning).toContain(darwinNode); + }); +}); diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index b0444af4f..b183f56f8 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -1,5 +1,9 @@ +import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; + +import { isSupportedNodeVersion } from "../infra/runtime-guard.js"; const VERSION_MANAGER_MARKERS = [ "/.nvm/", @@ -48,6 +52,37 @@ function buildSystemNodeCandidates( return []; } +type ExecFileAsync = ( + file: string, + args: readonly string[], + options: { encoding: "utf8" }, +) => Promise<{ stdout: string; stderr: string }>; + +const execFileAsync = promisify(execFile) as unknown as ExecFileAsync; + +async function resolveNodeVersion( + nodePath: string, + execFileImpl: ExecFileAsync, +): Promise { + try { + const { stdout } = await execFileImpl( + nodePath, + ["-p", "process.versions.node"], + { encoding: "utf8" }, + ); + const value = stdout.trim(); + return value ? value : null; + } catch { + return null; + } +} + +export type SystemNodeInfo = { + path: string; + version: string | null; + supported: boolean; +}; + export function isVersionManagedNodePath( nodePath: string, platform: NodeJS.Platform = process.platform, @@ -84,11 +119,47 @@ export async function resolveSystemNodePath( return null; } +export async function resolveSystemNodeInfo(params: { + env?: Record; + platform?: NodeJS.Platform; + execFile?: ExecFileAsync; +}): Promise { + const env = params.env ?? process.env; + const platform = params.platform ?? process.platform; + const systemNode = await resolveSystemNodePath(env, platform); + if (!systemNode) return null; + + const version = await resolveNodeVersion( + systemNode, + params.execFile ?? execFileAsync, + ); + return { + path: systemNode, + version, + supported: isSupportedNodeVersion(version), + }; +} + +export function renderSystemNodeWarning( + systemNode: SystemNodeInfo | null, + selectedNodePath?: string, +): string | null { + if (!systemNode || systemNode.supported) return null; + const versionLabel = systemNode.version ?? "unknown"; + const selectedLabel = selectedNodePath + ? ` Using ${selectedNodePath} for the daemon.` + : ""; + return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22+.${selectedLabel} Install Node 22+ from nodejs.org or Homebrew.`; +} + export async function resolvePreferredNodePath(params: { env?: Record; runtime?: string; + platform?: NodeJS.Platform; + execFile?: ExecFileAsync; }): Promise { if (params.runtime !== "node") return undefined; - const systemNode = await resolveSystemNodePath(params.env); - return systemNode ?? undefined; + const systemNode = await resolveSystemNodeInfo(params); + if (!systemNode?.supported) return undefined; + return systemNode.path; } diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index 3037e2e6c..198628511 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -58,6 +58,10 @@ export function runtimeSatisfies(details: RuntimeDetails): boolean { return false; } +export function isSupportedNodeVersion(version: string | null): boolean { + return isAtLeast(parseSemver(version), MIN_NODE); +} + export function assertSupportedRuntime( runtime: RuntimeEnv = defaultRuntime, details: RuntimeDetails = detectRuntime(), diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index d03f2b179..1f9f854e2 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -53,7 +53,11 @@ import { } from "../config/config.js"; import { resolveGatewayLaunchAgentLabel } from "../daemon/constants.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; -import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; +import { + renderSystemNodeWarning, + resolvePreferredNodePath, + resolveSystemNodeInfo, +} from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; @@ -672,6 +676,14 @@ export async function runOnboardingWizard( runtime: daemonRuntime, nodePath, }); + if (daemonRuntime === "node") { + const systemNode = await resolveSystemNodeInfo({ env: process.env }); + const warning = renderSystemNodeWarning( + systemNode, + programArguments[0], + ); + if (warning) await prompter.note(warning, "Gateway runtime"); + } const environment = buildServiceEnvironment({ env: process.env, port,