fix(platform): detect Windows fnm CLI shims under GUI-launched PATH (#3062)

Scan %LOCALAPPDATA%\fnm\node-versions, fnm_multishells session dirs,
and %APPDATA%\npm when building wellKnownUserToolchainBins on Windows
so packaged Open Design finds Codex and other npm globals installed via
fnm even when launched from Start Menu without an initialized shell PATH.
This commit is contained in:
wuyangfan 2026-05-27 21:48:07 +08:00
parent a6a56099ca
commit 814beb40d3
3 changed files with 77 additions and 0 deletions

View file

@ -363,6 +363,30 @@ test('resolveAgentExecutable accepts Windows CODEX_BIN overrides with executable
}
});
test('resolveAgentExecutable searches Windows fnm multishell shims under a minimal GUI PATH (#3062)', () => {
const home = mkdtempSync(join(tmpdir(), 'od-agent-fnm-multishell-'));
const localAppData = join(home, 'AppData', 'Local');
const fnmMultishell = join(localAppData, 'fnm_multishells', '12345-abcd');
try {
return withEnvSnapshot(['PATH', 'PATHEXT', 'OD_AGENT_HOME', 'LOCALAPPDATA'], () => {
mkdirSync(fnmMultishell, { recursive: true });
writeFileSync(join(fnmMultishell, 'codex.CMD'), '@echo off\r\nexit /b 0\r\n');
process.env.OD_AGENT_HOME = home;
process.env.LOCALAPPDATA = localAppData;
process.env.PATH = '';
process.env.PATHEXT = '.EXE;.CMD;.BAT';
const resolved = withPlatform('win32', () =>
resolveAgentExecutable(minimalAgentDef({ id: 'codex', bin: 'codex' })),
);
assert.equal(resolved, join(fnmMultishell, 'codex.CMD'));
});
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('detectAgents applies configured env while probing the CLI', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-agent-env-'));
try {

View file

@ -625,6 +625,28 @@ export function wellKnownUserToolchainBins(
dirs.push(dir);
}
}
if (process.platform === "win32") {
const localAppData =
resolveUserScopedHome(env.LOCALAPPDATA, home) ?? join(home, "AppData", "Local");
const appData =
resolveUserScopedHome(env.APPDATA, home) ?? join(home, "AppData", "Roaming");
dirs.push(join(appData, "npm"));
const fnmDir = typeof env.FNM_DIR === "string" ? env.FNM_DIR.trim() : "";
for (const root of [
join(localAppData, "fnm", "node-versions"),
...(fnmDir.length > 0 ? [join(fnmDir, "node-versions")] : []),
]) {
for (const dir of existingChildBinDirs(root, ["installation", "bin"])) {
dirs.push(dir);
}
}
// fnm exposes npm-global CLIs via per-shell shims under fnm_multishells
// after `fnm env`. GUI-launched Electron inherits a stripped PATH without
// those ephemeral dirs (issue #3062).
for (const dir of existingChildBinDirs(join(localAppData, "fnm_multishells"), [])) {
dirs.push(dir);
}
}
return dirs;
}

View file

@ -669,4 +669,35 @@ describe("wellKnownUserToolchainBins", () => {
rmSync(home, { recursive: true, force: true });
}
});
it("includes Windows fnm node installs, multishell shims, and npm globals (#3062)", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
const home = mkdtempSync(join(tmpdir(), "wkutb-win-fnm-"));
const localAppData = join(home, "AppData", "Local");
const appData = join(home, "AppData", "Roaming");
const fnmNodeBin = join(localAppData, "fnm", "node-versions", "v20.20.2", "installation", "bin");
const fnmMultishell = join(localAppData, "fnm_multishells", "12345-abcd");
const npmGlobal = join(appData, "npm");
try {
mkdirSync(fnmNodeBin, { recursive: true });
mkdirSync(fnmMultishell, { recursive: true });
mkdirSync(npmGlobal, { recursive: true });
writeFileSync(join(fnmNodeBin, "marker"), "");
writeFileSync(join(fnmMultishell, "marker"), "");
writeFileSync(join(npmGlobal, "marker"), "");
const dirs = wellKnownUserToolchainBins({
home,
env: { LOCALAPPDATA: localAppData, APPDATA: appData },
includeSystemBins: false,
});
expect(dirs).toContain(fnmNodeBin);
expect(dirs).toContain(fnmMultishell);
expect(dirs).toContain(npmGlobal);
} finally {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
rmSync(home, { recursive: true, force: true });
}
});
});