import { access, mkdir, readFile, rm, symlink, writeFile, } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CLAUDE_DESKTOP_PACKAGE_FAMILY } from "../../../src/application/ClaudeDesktopConfigLocation.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { npxRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js"; import { configureClientConfiguration, inspectClientConfiguration, } from "../../../src/application/SetupClientConfiguration.js"; import { detectClients } from "../../../src/application/SetupHost.js"; import { supportedClients, type SetupClient, } from "../../../src/application/SupportedClients.js"; import { runUninstall, systemUninstallHost, } from "../../../src/application/Uninstall.js"; import { clearClientLocationEnvironment } from "../../fixtures/clientEnvironment.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; /** The Windows registration setup writes, independent of the test host. */ const command = npxRegistrationCommand("win32"); const CONFIG = "claude_desktop_config.json"; const roots: string[] = []; beforeEach(clearClientLocationEnvironment); afterEach(async () => { vi.unstubAllEnvs(); await Promise.all( roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), ); }); /** A Windows profile with separate roaming and local AppData roots. */ const windowsProfile = async () => { const home = await createTestTempDirectory("rea-claude-desktop-"); roots.push(home); const env = { APPDATA: join(home, "AppData", "Roaming"), LOCALAPPDATA: join(home, "AppData", "Local"), }; const appDataConfig = join(env.APPDATA, "Claude", CONFIG); const container = join( env.LOCALAPPDATA, "Packages", CLAUDE_DESKTOP_PACKAGE_FAMILY, ); const privateConfig = join( container, "LocalCache", "Roaming", "Claude", CONFIG, ); return { home, env, appDataConfig, container, privateConfig }; }; const claudeDesktop = ( home: string, env: Readonly>, ): SetupClient => { const client = supportedClients(home, "win32", env).find( ({ name }) => name === "claude_desktop", ); if (client === undefined) throw new Error("claude_desktop is not supported"); return client; }; const writeJson = async (path: string, value: unknown): Promise => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); }; const readJson = async (path: string): Promise> => JSON.parse(await readFile(path, "utf8")) as Record; const reaEntry = { command: command[0], args: command.slice(1) }; describe("Windows Claude Desktop configuration location", () => { it("uses AppData when no MSIX package storage exists", async () => { const { home, env, appDataConfig } = await windowsProfile(); await mkdir( join(env.LOCALAPPDATA, "Packages", "Contoso.App_8wekyb3d8bbwe"), { recursive: true, }, ); const client = claudeDesktop(home, env); expect(client.configPath).toBe(appDataConfig); expect(client.markerPath).toBe(dirname(appDataConfig)); expect(client.msixPackage).toBeUndefined(); expect(client.quitBeforeSetup).toBe(true); }); it("uses the package-private copy that the MSIX package reads", async () => { const { home, env, appDataConfig, container, privateConfig } = await windowsProfile(); await writeJson(privateConfig, { preferences: { theme: "dark" } }); await writeJson(appDataConfig, { mcpServers: {} }); const client = claudeDesktop(home, env); expect(client.configPath).toBe(privateConfig); expect(client.markerPath).toBe(container); expect(client.msixPackage).toEqual({ packageFamilyName: CLAUDE_DESKTOP_PACKAGE_FAMILY, privateConfigPath: privateConfig, appDataConfigPath: appDataConfig, virtualizedRoots: [env.APPDATA, env.LOCALAPPDATA], processView: "direct", }); expect( (await detectClients(home, "win32", env)).map(({ name }) => name), ).toContain("claude_desktop"); }); it("uses AppData while the MSIX package has no private copy", async () => { const { home, env, appDataConfig, container, privateConfig } = await windowsProfile(); await mkdir(container, { recursive: true }); await writeJson(appDataConfig, { preferences: {} }); const client = claudeDesktop(home, env); expect(client.configPath).toBe(appDataConfig); expect(client.msixPackage).toMatchObject({ privateConfigPath: privateConfig, processView: "unknown", }); // No private file shows whether the running package started this process. const inspection = await inspectClientConfiguration(client, {}, command); expect(inspection.status).toBe("invalid"); expect(inspection.status === "invalid" && inspection.remediation).toContain( "Open Claude Desktop once if it has never run, quit it fully, then rerun setup", ); }); it("creates the private copy when the MSIX package has no configuration", async () => { const { home, env, privateConfig } = await windowsProfile(); await writeJson(join(dirname(privateConfig), "Local State"), {}); const client = claudeDesktop(home, env); expect(client.configPath).toBe(privateConfig); expect(client.msixPackage?.processView).toBe("direct"); await expect( inspectClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "create" }); await expect( configureClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "configured" }); expect(await readJson(privateConfig)).toEqual({ mcpServers: { rea: reaEntry }, }); }); it("reports an unrecognized Claude package instead of falling back to AppData", async () => { const { home, env, appDataConfig } = await windowsProfile(); const foreign = join(env.LOCALAPPDATA, "Packages", "Claude_abcdefghjkmnp"); await mkdir(foreign, { recursive: true }); await writeJson(appDataConfig, { mcpServers: {} }); const client = claudeDesktop(home, env); expect(client.configPath).toBe(foreign); expect(client.configPathError).toContain("unrecognized publisher"); expect(client.configPathError).toContain(CLAUDE_DESKTOP_PACKAGE_FAMILY); expect( (await detectClients(home, "win32", env)).map(({ name }) => name), ).not.toContain("claude_desktop"); const status = ( await readClientRegistrationStatuses(home, "unused", { platform: "win32", environment: env, }) ).find(({ client: name }) => name === "claude_desktop"); expect(status).toMatchObject({ state: "invalid", config_path: foreign }); await expect( inspectClientConfiguration(client, {}, command), ).resolves.toMatchObject({ status: "invalid" }); }); }); describe("MSIX Claude Desktop registration lifecycle", () => { it("registers in the private copy and reports an AppData-only entry as missing", async () => { const { home, env, appDataConfig, privateConfig } = await windowsProfile(); const privateOriginal = { coworkUserFilesPath: "C:\\Users\\a\\Claude", preferences: { menuBarEnabled: false }, mcpServers: { other: { command: "other", args: ["serve"] } }, }; await writeJson(privateConfig, privateOriginal); // Earlier setups wrote AppData, which the package never reads. await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } }); const appDataOriginal = await readFile(appDataConfig, "utf8"); const status = async () => ( await readClientRegistrationStatuses(home, "unused", { platform: "win32", environment: env, }) ).find(({ client }) => client === "claude_desktop"); expect(await status()).toMatchObject({ state: "missing", config_path: privateConfig, }); expect((await status())?.remediation).toContain( "Quit Claude Desktop fully", ); const client = claudeDesktop(home, env); await expect( inspectClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "update", backupPath: `${privateConfig}.rea.backup`, }); await expect( configureClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "configured", backupPath: `${privateConfig}.rea.backup`, }); expect(await readJson(privateConfig)).toEqual({ ...privateOriginal, mcpServers: { ...privateOriginal.mcpServers, rea: reaEntry, }, }); expect(await readJson(`${privateConfig}.rea.backup`)).toEqual( privateOriginal, ); expect(await readFile(appDataConfig, "utf8")).toBe(appDataOriginal); expect(await status()).toMatchObject({ state: "aligned", config_path: privateConfig, }); await expect( configureClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "unchanged" }); }); it("refuses to write from a process the running package started", async () => { const { home, env, privateConfig } = await windowsProfile(); await writeJson(privateConfig, { preferences: {} }); const resolved = claudeDesktop(home, env); if (resolved.msixPackage === undefined) throw new Error("expected MSIX package storage"); // Only a packaged process sees AppData redirected; model that observation. const client: SetupClient = { ...resolved, msixPackage: { ...resolved.msixPackage, processView: "redirected" }, }; const inspection = await inspectClientConfiguration(client, {}, command); expect(inspection.status).toBe("invalid"); expect(inspection.status === "invalid" && inspection.remediation).toContain( "Quit Claude Desktop fully, then rerun setup from a terminal that Claude Desktop did not start", ); expect(await readJson(privateConfig)).toEqual({ preferences: {} }); await configureClientConfiguration(resolved, {}, command); await expect( inspectClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "already_current" }); }); it("refuses a write once the package reads a newer private copy", async () => { const { home, env, appDataConfig, privateConfig } = await windowsProfile(); await writeJson(join(dirname(privateConfig), "Preferences"), {}); await writeJson(appDataConfig, { preferences: {} }); const client = claudeDesktop(home, env); expect(client.configPath).toBe(appDataConfig); // Quitting the app after the plan was shown can save its private copy. await writeJson(privateConfig, { preferences: {} }); await expect( configureClientConfiguration(client, {}, command), ).resolves.toEqual({ status: "failed", reason: "target_changed" }); expect(await readJson(appDataConfig)).toEqual({ preferences: {} }); expect(await readJson(privateConfig)).toEqual({ preferences: {} }); }); it("registers in AppData while the package has no private copy", async () => { const { home, env, appDataConfig, privateConfig } = await windowsProfile(); await writeJson(join(dirname(privateConfig), "Preferences"), {}); await writeJson(appDataConfig, { preferences: { theme: "light" } }); const client = claudeDesktop(home, env); expect(client.configPath).toBe(appDataConfig); expect(client.msixPackage?.processView).toBe("direct"); await expect( configureClientConfiguration(client, {}, command), ).resolves.toMatchObject({ status: "configured" }); expect(await readJson(appDataConfig)).toEqual({ preferences: { theme: "light" }, mcpServers: { rea: reaEntry }, }); await expect(access(privateConfig)).rejects.toMatchObject({ code: "ENOENT", }); }); }); describe("MSIX Claude Desktop uninstall", () => { it("refuses uninstall when the package view is unknown", async () => { const { home, env, appDataConfig, container } = await windowsProfile(); await mkdir(container, { recursive: true }); await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } }); const result = await runUninstall( false, systemUninstallHost(home, undefined, env, "win32"), ); expect(result.status).toBe("failed"); expect(result.items).toContainEqual( expect.objectContaining({ name: "claude_desktop", status: "failed", detail: expect.stringContaining("rerun uninstall"), }), ); expect(await readJson(appDataConfig)).toEqual({ mcpServers: { rea: reaEntry }, }); }); it("retains an unrelated rea-named entry when the package view is unknown", async () => { const { home, env, appDataConfig, container } = await windowsProfile(); await mkdir(container, { recursive: true }); const unrelated = { mcpServers: { rea: { command: "unrelated-server" } } }; await writeJson(appDataConfig, unrelated); const result = await runUninstall( false, systemUninstallHost(home, undefined, env, "win32"), ); expect(result.status).toBe("complete"); expect(await readJson(appDataConfig)).toEqual(unrelated); }); it("blocks managed skill removal through redirected AppData before any removal", async () => { const { home, env, appDataConfig, privateConfig } = await windowsProfile(); await writeJson(join(dirname(privateConfig), "Preferences"), {}); await mkdir(dirname(dirname(appDataConfig)), { recursive: true }); await symlink(dirname(privateConfig), dirname(appDataConfig), "dir"); const agents = join(env.APPDATA, "dsh", "agents"); const manifest = join( agents, "skills", "reverse-engineer-anything", "SKILL.md", ); await mkdir(dirname(manifest), { recursive: true }); await writeFile( manifest, "---\nname: reverse-engineer-anything\n---\nmanaged fixture\n", ); const environment = { ...env, DSH_AGENTS_HOME: agents }; const result = await runUninstall( false, systemUninstallHost(home, undefined, environment, "win32"), ); expect(result.status).toBe("failed"); expect(result.items).toContainEqual( expect.objectContaining({ name: "skill", status: "failed", detail: expect.stringContaining("redirects its writes"), }), ); expect(await readFile(manifest, "utf8")).toContain("managed fixture"); }); it("removes REA from both the private copy and AppData on uninstall", async () => { const { home, env, appDataConfig, privateConfig } = await windowsProfile(); await writeJson(privateConfig, { preferences: {}, mcpServers: { other: { command: "other" }, rea: reaEntry }, }); await writeJson(appDataConfig, { mcpServers: { rea: reaEntry } }); const result = await runUninstall( false, systemUninstallHost(home, undefined, env, "win32"), ); expect(result.status).toBe("complete"); expect(await readJson(privateConfig)).toEqual({ preferences: {}, mcpServers: { other: { command: "other" } }, }); expect(await readJson(appDataConfig)).toEqual({ mcpServers: {} }); }); });