mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
fix(setup): derive client config roots from each client's own reader (#10596)
Configuration roots read from XDG_CONFIG_HOME, VSCODE_APPDATA and VSCODE_PORTABLE now come from one module whose functions reproduce a named reader: xdg-basedir (OpenCode, Kilo), the XDG rule used by Rust dirs and etcetera (Zed, Goose), and VS Code's getUserDataPath. mcp add's ad hoc platformPaths() is removed. Zed no longer takes a relative XDG_CONFIG_HOME that Zed ignores, and the Cline extension and Kilo's VS Code marker follow VS Code's portable and VSCODE_APPDATA selection.
This commit is contained in:
1 parent
a09d3bed94
commit
f5c8c1f9e0
6 files changed
+197
-66
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
absoluteXdgConfigHome,
|
||||
xdgConfigHomeOrDefault,
|
||||
} from "./ClientConfigRoots.js";
|
||||
|
||||
const home = "/home/user";
|
||||
|
||||
it.each([
|
||||
[undefined, "/home/user/.config", "/home/user/.config"],
|
||||
["", "/home/user/.config", "/home/user/.config"],
|
||||
["relative", "relative", "/home/user/.config"],
|
||||
["/custom", "/custom", "/custom"],
|
||||
])(
|
||||
"reads XDG_CONFIG_HOME=%j as xdg-basedir %s and the XDG rule %s",
|
||||
(value, basedir, specification) => {
|
||||
const env = value === undefined ? {} : { XDG_CONFIG_HOME: value };
|
||||
expect(xdgConfigHomeOrDefault({ home, env })).toBe(basedir);
|
||||
expect(absoluteXdgConfigHome({ home, env })).toBe(specification);
|
||||
},
|
||||
);
|
||||
|
||||
// Client root semantics differ per reader, so they are derived only here.
|
||||
it("keeps client configuration-root variables in one owner", async () => {
|
||||
const directory = fileURLToPath(new URL(".", import.meta.url));
|
||||
const offenders: string[] = [];
|
||||
for (const name of await readdir(directory)) {
|
||||
if (
|
||||
!name.endsWith(".ts") ||
|
||||
name.endsWith(".test.ts") ||
|
||||
name === "ClientConfigRoots.ts"
|
||||
)
|
||||
continue;
|
||||
const text = await readFile(join(directory, name), "utf8");
|
||||
if (/\.(?:XDG_CONFIG_HOME|VSCODE_APPDATA|VSCODE_PORTABLE)\b/u.test(text))
|
||||
offenders.push(name);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { isAbsolute, join, posix, resolve, win32 } from "node:path";
|
||||
|
||||
import { windowsAppDataRoots } from "./MsixPackageStorage.js";
|
||||
|
||||
/**
|
||||
* Configuration roots that clients derive from environment variables. Each
|
||||
* function reproduces one reader's semantics for empty and relative values,
|
||||
* named after the code that owns them, so a client location states which rule
|
||||
* it follows instead of re-deriving it.
|
||||
*/
|
||||
export interface ClientRootContext {
|
||||
readonly home: string;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly env: Readonly<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `XDG_CONFIG_HOME || ~/.config`: an empty value is unset and a relative value
|
||||
* is kept, resolving against the reader's working directory. This is the
|
||||
* xdg-basedir package (OpenCode, Kilo) and VS Code's `getDefaultUserDataPath`
|
||||
* on Linux. Clients that do not document their reader also use it; it is not
|
||||
* established for them.
|
||||
*/
|
||||
export const xdgConfigHomeOrDefault = ({
|
||||
home,
|
||||
env,
|
||||
}: Pick<ClientRootContext, "home" | "env">): string =>
|
||||
env.XDG_CONFIG_HOME || join(home, ".config");
|
||||
|
||||
/**
|
||||
* The XDG Base Directory rule: only an absolute `XDG_CONFIG_HOME` counts;
|
||||
* empty and relative values are invalid and fall back to `~/.config`. This is
|
||||
* Rust `dirs::config_dir` on Linux (Zed) and etcetera's XDG strategy (Goose).
|
||||
*/
|
||||
export const absoluteXdgConfigHome = ({
|
||||
home,
|
||||
env,
|
||||
}: Pick<ClientRootContext, "home" | "env">): string => {
|
||||
const value = env.XDG_CONFIG_HOME;
|
||||
return value !== undefined && posix.isAbsolute(value)
|
||||
? value
|
||||
: join(home, ".config");
|
||||
};
|
||||
|
||||
/**
|
||||
* VS Code's `getUserDataPath`: non-empty `VSCODE_PORTABLE` selects portable
|
||||
* storage, then non-empty `VSCODE_APPDATA`, then the platform default. A
|
||||
* relative result resolves against `VSCODE_CWD`. Extensions such as Cline
|
||||
* keep their global storage under this directory.
|
||||
*/
|
||||
export const vscodeUserDirectory = ({
|
||||
home,
|
||||
platform,
|
||||
env,
|
||||
}: ClientRootContext): string => {
|
||||
const userData = env.VSCODE_PORTABLE
|
||||
? join(env.VSCODE_PORTABLE, "user-data")
|
||||
: env.VSCODE_APPDATA
|
||||
? join(env.VSCODE_APPDATA, "Code")
|
||||
: platform === "win32"
|
||||
? join(windowsAppDataRoots(home, env).roaming, "Code")
|
||||
: platform === "darwin"
|
||||
? join(home, "Library", "Application Support", "Code")
|
||||
: join(xdgConfigHomeOrDefault({ home, env }), "Code");
|
||||
// The catalog can describe Windows clients on another host; retain its
|
||||
// drive-absolute paths instead of resolving them against that host cwd.
|
||||
const absoluteUserData =
|
||||
platform === "win32" && win32.isAbsolute(userData) && !isAbsolute(userData)
|
||||
? userData
|
||||
: isAbsolute(userData)
|
||||
? resolve(userData)
|
||||
: resolve(env.VSCODE_CWD || process.cwd(), userData);
|
||||
return join(absoluteUserData, "User");
|
||||
};
|
||||
@@ -246,7 +246,7 @@ it("uses Cline's literal fallback for an empty POSIX HOME and refuses unknown sy
|
||||
);
|
||||
});
|
||||
|
||||
it("selects Goose and Zed native settings paths for their platforms and overrides", async () => {
|
||||
it("selects Goose, Zed and Cline extension native settings paths for their platforms and overrides", async () => {
|
||||
const cases = [
|
||||
{
|
||||
agent: "goose",
|
||||
@@ -278,6 +278,28 @@ it("selects Goose and Zed native settings paths for their platforms and override
|
||||
environment: { XDG_CONFIG_HOME: "/custom" },
|
||||
expected: "/custom/zed/settings.json",
|
||||
},
|
||||
// Zed's dirs::config_dir ignores a relative XDG_CONFIG_HOME.
|
||||
{
|
||||
agent: "zed",
|
||||
platform: "linux" as const,
|
||||
environment: { XDG_CONFIG_HOME: "relative" },
|
||||
expected: "/home/user/.config/zed/settings.json",
|
||||
},
|
||||
// The Cline extension stores settings in VS Code's selected user data.
|
||||
{
|
||||
agent: "cline",
|
||||
platform: "linux" as const,
|
||||
environment: { VSCODE_APPDATA: "/vscode-data" },
|
||||
expected:
|
||||
"/vscode-data/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
},
|
||||
{
|
||||
agent: "cline",
|
||||
platform: "darwin" as const,
|
||||
environment: { VSCODE_PORTABLE: "/portable" },
|
||||
expected:
|
||||
"/portable/user-data/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
|
||||
},
|
||||
];
|
||||
for (const { agent, platform, environment, expected } of cases) {
|
||||
const resolved = await resolveMcpRegistrationClients(
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import {
|
||||
vscodeUserDirectory,
|
||||
xdgConfigHomeOrDefault,
|
||||
} from "./ClientConfigRoots.js";
|
||||
import { parseClientConfiguration } from "./ClientConfigurationDocument.js";
|
||||
import {
|
||||
resolveKiloRegistrationWorktree,
|
||||
@@ -7,7 +11,7 @@ import {
|
||||
} from "./McpRegistrationGit.js";
|
||||
import {
|
||||
selectedPath,
|
||||
platformPaths,
|
||||
rootContext,
|
||||
present,
|
||||
type McpRegistrationLocationContext,
|
||||
type McpRegistrationLocation,
|
||||
@@ -67,8 +71,12 @@ const kiloInline = (
|
||||
);
|
||||
}
|
||||
};
|
||||
/** Kilo's Global.Path.config: xdg-basedir's config home joined with "kilo". */
|
||||
const kiloRoot = (context: McpRegistrationLocationContext): string =>
|
||||
selectedPath(join(platformPaths(context).config, "kilo"), context);
|
||||
selectedPath(
|
||||
join(xdgConfigHomeOrDefault(rootContext(context)), "kilo"),
|
||||
context,
|
||||
);
|
||||
const kiloHomeDirectories = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<KiloConfigDirectory[]> => {
|
||||
@@ -162,7 +170,7 @@ export const kiloLocation = async (
|
||||
markers: [
|
||||
root,
|
||||
join(
|
||||
platformPaths(context).vscode,
|
||||
vscodeUserDirectory(rootContext(context)),
|
||||
"globalStorage",
|
||||
"kilocode.kilo-code",
|
||||
),
|
||||
|
||||
@@ -3,6 +3,12 @@ import { dirname, join, posix, win32 } from "node:path";
|
||||
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import {
|
||||
absoluteXdgConfigHome,
|
||||
vscodeUserDirectory,
|
||||
type ClientRootContext,
|
||||
} from "./ClientConfigRoots.js";
|
||||
import { windowsAppDataRoots } from "./MsixPackageStorage.js";
|
||||
import type { SetupClient, SupportedClientName } from "./SupportedClients.js";
|
||||
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import type { ClientConfigurationDocument } from "./ClientConfigurationDocument.js";
|
||||
@@ -64,17 +70,20 @@ export const fromSetup =
|
||||
};
|
||||
};
|
||||
|
||||
export const platformPaths = (context: McpRegistrationLocationContext) => {
|
||||
const { home, platform, environment } = context;
|
||||
const config = environment.XDG_CONFIG_HOME || join(home, ".config");
|
||||
const appSupport =
|
||||
platform === "win32"
|
||||
? environment.APPDATA || win32.join(home, "AppData", "Roaming")
|
||||
: platform === "darwin"
|
||||
? join(home, "Library", "Application Support")
|
||||
: config;
|
||||
return { config, appSupport, vscode: join(appSupport, "Code", "User") };
|
||||
};
|
||||
/** The client-root context of one registration selection. */
|
||||
export const rootContext = ({
|
||||
home,
|
||||
platform,
|
||||
environment,
|
||||
}: McpRegistrationLocationContext): ClientRootContext => ({
|
||||
home,
|
||||
platform,
|
||||
env: environment,
|
||||
});
|
||||
|
||||
/** Windows roaming application data, read as setup reads it. */
|
||||
const windowsRoaming = (context: McpRegistrationLocationContext): string =>
|
||||
windowsAppDataRoots(context.home, context.environment).roaming;
|
||||
|
||||
const location = (
|
||||
path: string,
|
||||
@@ -112,7 +121,7 @@ export const clineLocation = async (
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
location(
|
||||
join(
|
||||
platformPaths(context).vscode,
|
||||
vscodeUserDirectory(rootContext(context)),
|
||||
"globalStorage",
|
||||
"saoudrizwan.claude-dev",
|
||||
"settings",
|
||||
@@ -129,18 +138,12 @@ export const gooseLocation = async (
|
||||
const selected = pathApi.join(root, "config", "config.yaml");
|
||||
return location(selected, selected);
|
||||
}
|
||||
const xdg = environment.XDG_CONFIG_HOME;
|
||||
// etcetera's Windows and XDG strategies; only an absolute XDG root counts.
|
||||
const path =
|
||||
platform === "win32"
|
||||
? join(
|
||||
platformPaths(context).appSupport,
|
||||
"Block",
|
||||
"goose",
|
||||
"config",
|
||||
"config.yaml",
|
||||
)
|
||||
? join(windowsRoaming(context), "Block", "goose", "config", "config.yaml")
|
||||
: join(
|
||||
xdg && posix.isAbsolute(xdg) ? xdg : join(home, ".config"),
|
||||
absoluteXdgConfigHome({ home, env: environment }),
|
||||
"goose",
|
||||
"config.yaml",
|
||||
);
|
||||
@@ -186,14 +189,22 @@ export const mcporterLocation = async (
|
||||
export const zedLocation = async (
|
||||
context: McpRegistrationLocationContext,
|
||||
): Promise<McpRegistrationGlobalLocation> =>
|
||||
// Zed's paths::config_dir. On Windows it reads the RoamingAppData known
|
||||
// folder, which APPDATA normally names; on Linux, `dirs::config_dir`
|
||||
// admits only an absolute XDG_CONFIG_HOME.
|
||||
location(
|
||||
context.platform === "darwin"
|
||||
? join(context.home, ".config", "zed", "settings.json")
|
||||
: join(
|
||||
platformPaths(context).appSupport,
|
||||
context.platform === "win32" ? "Zed" : "zed",
|
||||
"settings.json",
|
||||
),
|
||||
: context.platform === "win32"
|
||||
? join(windowsRoaming(context), "Zed", "settings.json")
|
||||
: join(
|
||||
absoluteXdgConfigHome({
|
||||
home: context.home,
|
||||
env: context.environment,
|
||||
}),
|
||||
"zed",
|
||||
"settings.json",
|
||||
),
|
||||
);
|
||||
|
||||
export const fxLocation = async (
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
} from "./HermesClientHome.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||
import {
|
||||
vscodeUserDirectory,
|
||||
xdgConfigHomeOrDefault,
|
||||
} from "./ClientConfigRoots.js";
|
||||
import { resolveWindowsClaudeDesktopLocation } from "./ClaudeDesktopConfigLocation.js";
|
||||
import { resolveClineCliSettingsPath } from "./ClineCliLocation.js";
|
||||
import {
|
||||
@@ -145,37 +149,6 @@ interface ClientDefinitionBase {
|
||||
) => NonNullable<SetupClient["geminiSettings"]>;
|
||||
}
|
||||
|
||||
/** XDG treats an empty config-home value as unset. */
|
||||
const xdgConfigHome = ({ home, env }: ClientPathContext): string =>
|
||||
env.XDG_CONFIG_HOME || join(home, ".config");
|
||||
|
||||
const vscodeUserDirectory = ({
|
||||
home,
|
||||
platform,
|
||||
env,
|
||||
}: ClientPathContext): string => {
|
||||
// Match VS Code's user-data selection before appending the User scope.
|
||||
// Empty overrides are unset; portable storage takes precedence over appdata.
|
||||
const userData = env.VSCODE_PORTABLE
|
||||
? join(env.VSCODE_PORTABLE, "user-data")
|
||||
: env.VSCODE_APPDATA
|
||||
? join(env.VSCODE_APPDATA, "Code")
|
||||
: platform === "win32"
|
||||
? join(windowsAppDataRoots(home, env).roaming, "Code")
|
||||
: platform === "darwin"
|
||||
? join(home, "Library", "Application Support", "Code")
|
||||
: join(xdgConfigHome({ home, platform, env }), "Code");
|
||||
// The catalog can describe Windows clients on another host; retain its
|
||||
// drive-absolute paths instead of resolving them against that host cwd.
|
||||
const absoluteUserData =
|
||||
platform === "win32" && win32.isAbsolute(userData) && !isAbsolute(userData)
|
||||
? userData
|
||||
: isAbsolute(userData)
|
||||
? resolve(userData)
|
||||
: resolve(env.VSCODE_CWD || process.cwd(), userData);
|
||||
return join(absoluteUserData, "User");
|
||||
};
|
||||
|
||||
const traeUserDirectory = ({
|
||||
home,
|
||||
platform,
|
||||
@@ -185,7 +158,7 @@ const traeUserDirectory = ({
|
||||
return join(windowsAppDataRoots(home, env).roaming, "Trae", "User");
|
||||
if (platform === "darwin")
|
||||
return join(home, "Library", "Application Support", "Trae", "User");
|
||||
return join(xdgConfigHome({ home, platform, env }), "Trae", "User");
|
||||
return join(xdgConfigHomeOrDefault({ home, env }), "Trae", "User");
|
||||
};
|
||||
|
||||
/** Windows packaging decides which file Claude Desktop reads; see its resolver. */
|
||||
@@ -196,7 +169,7 @@ const claudeDesktopLocation = (
|
||||
return resolveWindowsClaudeDesktopLocation(context.home, context.env);
|
||||
const directory =
|
||||
context.platform === "linux"
|
||||
? join(xdgConfigHome(context), "Claude")
|
||||
? join(xdgConfigHomeOrDefault(context), "Claude")
|
||||
: join(context.home, "Library", "Application Support", "Claude");
|
||||
return ok({
|
||||
configPath: join(directory, "claude_desktop_config.json"),
|
||||
@@ -468,13 +441,13 @@ const openCodeOverrideDirectory = (
|
||||
|
||||
const opencodeDirectory = (context: ClientPathContext): string =>
|
||||
openCodeOverrideDirectory(context.env) ??
|
||||
join(xdgConfigHome(context), "opencode");
|
||||
join(xdgConfigHomeOrDefault(context), "opencode");
|
||||
|
||||
/** OpenCode loads global JSON before JSONC, then explicit file and directory overrides. */
|
||||
const openCodeConfigurationPaths = (
|
||||
context: ClientPathContext,
|
||||
): readonly string[] => {
|
||||
const directory = join(xdgConfigHome(context), "opencode");
|
||||
const directory = join(xdgConfigHomeOrDefault(context), "opencode");
|
||||
const overrideDirectory = openCodeOverrideDirectory(context.env);
|
||||
const paths = [
|
||||
join(directory, "config.json"),
|
||||
@@ -565,7 +538,7 @@ const geminiSettings = ({
|
||||
const devinDirectory = ({ home, platform, env }: ClientPathContext): string =>
|
||||
platform === "win32"
|
||||
? join(windowsAppDataRoots(home, env).roaming, "devin")
|
||||
: join(env.XDG_CONFIG_HOME || join(home, ".config"), "devin");
|
||||
: join(xdgConfigHomeOrDefault({ home, env }), "devin");
|
||||
|
||||
/**
|
||||
* FreeBuff's own profile directory (its `state.json` root, fixed under
|
||||
|
||||
Reference in new issue
Block a user