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", + }); + }, +);