From c339f9ebe7bd1f30608d6ccb7175da523dd3c485 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:11:55 +0800 Subject: [PATCH] fix(setup): ignore empty home directory selections Skip empty home environment values while preserving nonempty caller selections and platform precedence. Setup, doctor, and uninstall now use the same fallback home instead of writing relative paths in the current directory. Validation: focused home tests and the public setup/doctor/uninstall lifecycle under Node 24.18. Package verification and pack dry run are included in the integrated gate. Fixes #2325 --- src/config/homeDirectory.ts | 24 ++- src/homeDirectory.test.ts | 64 ++++++++ .../setup/homeDirectoryFallback.test.ts | 139 ++++++++++++++++++ 3 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 src/homeDirectory.test.ts create mode 100644 tests/acceptance/setup/homeDirectoryFallback.test.ts diff --git a/src/config/homeDirectory.ts b/src/config/homeDirectory.ts index 4e135a1686..ee958a21c3 100644 --- a/src/config/homeDirectory.ts +++ b/src/config/homeDirectory.ts @@ -1,17 +1,29 @@ import { userInfo } from "node:os"; +const firstNonempty = (...values: readonly (string | undefined)[]) => + values.find( + (value): value is string => value !== undefined && value.length > 0, + ); + /** Resolve the caller's selected home before consulting the host account. */ export const homeDirectoryFromEnvironment = ( environment: Readonly, platform: NodeJS.Platform, ): string => { + const windowsDriveHome = + environment.HOMEDRIVE !== undefined && + environment.HOMEDRIVE.length > 0 && + environment.HOMEPATH !== undefined && + environment.HOMEPATH.length > 0 + ? `${environment.HOMEDRIVE}${environment.HOMEPATH}` + : undefined; const selected = platform === "win32" - ? (environment.USERPROFILE ?? - (environment.HOMEDRIVE !== undefined && - environment.HOMEPATH !== undefined - ? `${environment.HOMEDRIVE}${environment.HOMEPATH}` - : environment.HOME)) - : (environment.HOME ?? environment.USERPROFILE); + ? firstNonempty( + environment.USERPROFILE, + windowsDriveHome, + environment.HOME, + ) + : firstNonempty(environment.HOME, environment.USERPROFILE); return selected ?? userInfo().homedir; }; diff --git a/src/homeDirectory.test.ts b/src/homeDirectory.test.ts new file mode 100644 index 0000000000..076fdfc143 --- /dev/null +++ b/src/homeDirectory.test.ts @@ -0,0 +1,64 @@ +import { userInfo } from "node:os"; + +import { describe, expect, it } from "vitest"; + +import { homeDirectoryFromEnvironment } from "./config/homeDirectory.js"; + +describe("home directory environment selection", () => { + it("skips an empty HOME before selecting USERPROFILE on POSIX", () => { + expect( + homeDirectoryFromEnvironment( + { HOME: "", USERPROFILE: "relative/profile" }, + "linux", + ), + ).toBe("relative/profile"); + }); + + it("falls back to the account home when POSIX home variables are empty", () => { + expect( + homeDirectoryFromEnvironment({ HOME: "", USERPROFILE: "" }, "linux"), + ).toBe(userInfo().homedir); + }); + + it("preserves a nonempty relative Windows profile ahead of drive variables", () => { + expect( + homeDirectoryFromEnvironment( + { + USERPROFILE: "relative/profile", + HOMEDRIVE: "D:", + HOMEPATH: "\\Users\\account", + HOME: "other/home", + }, + "win32", + ), + ).toBe("relative/profile"); + }); + + it("uses the Windows drive pair when USERPROFILE is empty", () => { + expect( + homeDirectoryFromEnvironment( + { + USERPROFILE: "", + HOMEDRIVE: "D:", + HOMEPATH: "\\Users\\account", + HOME: "other/home", + }, + "win32", + ), + ).toBe("D:\\Users\\account"); + }); + + it("falls back to the account home when all Windows home values are empty", () => { + expect( + homeDirectoryFromEnvironment( + { + USERPROFILE: "", + HOMEDRIVE: "", + HOMEPATH: "", + HOME: "", + }, + "win32", + ), + ).toBe(userInfo().homedir); + }); +}); diff --git a/tests/acceptance/setup/homeDirectoryFallback.test.ts b/tests/acceptance/setup/homeDirectoryFallback.test.ts new file mode 100644 index 0000000000..ccdf168a88 --- /dev/null +++ b/tests/acceptance/setup/homeDirectoryFallback.test.ts @@ -0,0 +1,139 @@ +import { access, mkdir, readFile } from "node:fs/promises"; +import { userInfo } from "node:os"; +import { isAbsolute, join, relative, sep } from "node:path"; +import { parse } from "jsonc-parser"; + +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "setup dry run resolves empty home values without writing to the cwd", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-empty-home-dry-run-"); + const workingDirectory = join(directory, "cwd"); + await mkdir(workingDirectory); + const hostHome = userInfo().homedir; + const result = await cli.run({ + arguments: [ + "setup", + "--client", + "cursor", + "--skill=false", + "--dry-run", + "--json", + ], + cwd: workingDirectory, + environment: { + HOME: "", + USERPROFILE: "", + HOMEDRIVE: "", + HOMEPATH: "", + XDG_CONFIG_HOME: "", + }, + }); + + expect(result.exitCode, result.stdout).toBe(0); + const output = result.json as { + plannedActions?: Array<{ kind?: unknown; target?: unknown }>; + status?: unknown; + }; + expect(["planned", "ready"]).toContain(output.status); + const configTargets = (output.plannedActions ?? []) + .filter((action) => action.kind === "configure_client") + .map((action) => action.target); + for (const target of configTargets) { + expect(typeof target).toBe("string"); + if (typeof target !== "string") continue; + expect(isAbsolute(target)).toBe(true); + const relativeTarget = relative(hostHome, target); + expect( + relativeTarget === "" || + (!relativeTarget.startsWith(`..${sep}`) && relativeTarget !== ".."), + ).toBe(true); + } + expect(configTargets).not.toContain( + join(workingDirectory, ".cursor", "mcp.json"), + ); + await expect( + access(join(workingDirectory, ".cursor", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +cliTest.skipIf(process.platform === "win32")( + "setup, doctor, and uninstall use the nonempty fallback home when HOME is empty", + async ({ cli }) => { + const directory = await createTestTempDirectory( + "rea-empty-home-lifecycle-", + ); + const home = join(directory, "fallback-home"); + const workingDirectory = join(directory, "cwd"); + await mkdir(home); + await mkdir(workingDirectory); + const environment = { + HOME: "", + USERPROFILE: home, + XDG_CONFIG_HOME: join(home, ".config"), + }; + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: workingDirectory, + environment, + }); + + const setup = await run(["setup", "--client", "cursor", "--yes", "--json"]); + expect(setup.exitCode, setup.stderr).toBe(0); + expect(setup.json).toMatchObject({ status: "ready" }); + await access(join(home, ".cursor", "mcp.json")); + await access( + join(home, ".agents", "skills", "reverse-engineer-anything", "SKILL.md"), + ); + await expect( + access(join(workingDirectory, ".cursor", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect( + access(join(workingDirectory, ".agents", "skills")), + ).rejects.toMatchObject({ code: "ENOENT" }); + + const doctor = await run([ + "doctor", + "--client", + "cursor", + "--skill", + "--json", + ]); + expect(doctor.exitCode, doctor.stdout).toBe(0); + expect(doctor.json).toMatchObject({ healthy: true }); + + const uninstall = await run(["uninstall", "--json"]); + expect(uninstall.exitCode, uninstall.stderr).toBe(0); + expect(uninstall.json).toMatchObject({ status: "complete" }); + const remainingConfig = parse( + await readFile(join(home, ".cursor", "mcp.json"), "utf8"), + ); + expect(remainingConfig).not.toHaveProperty("mcpServers.rea"); + await expect( + access( + join( + home, + ".agents", + "skills", + "reverse-engineer-anything", + "SKILL.md", + ), + ), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + access(join(workingDirectory, ".cursor", "mcp.json")), + ).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +);