413 lines
15 KiB
TypeScript
413 lines
15 KiB
TypeScript
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<Record<string, string>>,
|
|
): 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<void> => {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
};
|
|
|
|
const readJson = async (path: string): Promise<Record<string, unknown>> =>
|
|
JSON.parse(await readFile(path, "utf8")) as Record<string, unknown>;
|
|
|
|
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: {} });
|
|
});
|
|
});
|