fix: improve daemon node selection

This commit is contained in:
Peter Steinberger
2026-01-12 08:33:28 +00:00
parent 1f63ee565f
commit 7db1cbe178
9 changed files with 268 additions and 10 deletions

View File

@@ -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);
});
});

View File

@@ -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<string | null> {
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<string, string | undefined>;
platform?: NodeJS.Platform;
execFile?: ExecFileAsync;
}): Promise<SystemNodeInfo | null> {
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<string, string | undefined>;
runtime?: string;
platform?: NodeJS.Platform;
execFile?: ExecFileAsync;
}): Promise<string | undefined> {
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;
}