mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 03:15:54 +00:00
239 lines
7.4 KiB
TypeScript
239 lines
7.4 KiB
TypeScript
import {
|
|
parseClientConfiguration,
|
|
serializeClientConfiguration,
|
|
type ClientConfigurationDocument,
|
|
} from "./ClientConfigurationDocument.js";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { dirname } from "node:path";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import writeFileAtomic from "write-file-atomic";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
|
import type {
|
|
ClientConfigurationInspection,
|
|
ClientConfigurationResult,
|
|
SetupProviderEnvironment,
|
|
} from "./Setup.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
|
|
const defaultCommand = (): readonly string[] => [
|
|
"npx",
|
|
"-y",
|
|
PRODUCT_IDENTITY.registrationPackageSpecifier,
|
|
"mcp",
|
|
];
|
|
|
|
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
|
export const configureJsonClient = (
|
|
client: SetupClient,
|
|
environment: SetupProviderEnvironment = {},
|
|
command: readonly string[] = defaultCommand(),
|
|
): Promise<ClientConfigurationResult> =>
|
|
configureClientDocument(client, environment, command, "json");
|
|
|
|
/** Back up, atomically update, and semantically read back one TOML MCP configuration. */
|
|
export const configureTomlClient = (
|
|
client: SetupClient,
|
|
environment: SetupProviderEnvironment = {},
|
|
command: readonly string[] = defaultCommand(),
|
|
): Promise<ClientConfigurationResult> =>
|
|
configureClientDocument(client, environment, command, "toml");
|
|
|
|
const configureClientDocument = async (
|
|
client: SetupClient,
|
|
environment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
format: "json" | "toml",
|
|
): Promise<ClientConfigurationResult> => {
|
|
const transactionPath = await resolveClientConfigTransactionPath(
|
|
client.configPath,
|
|
);
|
|
if (transactionPath === undefined)
|
|
return { status: "failed", reason: "path" };
|
|
let original: string | undefined;
|
|
try {
|
|
original = await readFile(transactionPath, "utf8");
|
|
} catch (cause: unknown) {
|
|
if (!isMissing(cause)) return { status: "failed", reason: "readback" };
|
|
}
|
|
let parsed: ClientConfigurationDocument;
|
|
try {
|
|
parsed = parseClientConfiguration(
|
|
original ?? (format === "toml" ? "" : "{}"),
|
|
format,
|
|
);
|
|
} catch {
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
const { document, servers, serversKey } = parsed;
|
|
const desired = clientConfigurationDesired(client, environment, command);
|
|
if (isDeepStrictEqual(servers[PRODUCT_IDENTITY.mcpServerKey], desired))
|
|
return { status: "unchanged" };
|
|
const backupPath =
|
|
original === undefined ? undefined : `${client.configPath}.rea.backup`;
|
|
if (
|
|
backupPath !== undefined &&
|
|
!(await preserveConfigBackup(transactionPath, backupPath))
|
|
)
|
|
return { status: "failed", reason: "backup" };
|
|
document[serversKey] = {
|
|
...servers,
|
|
[PRODUCT_IDENTITY.mcpServerKey]: desired,
|
|
};
|
|
try {
|
|
await mkdir(dirname(client.configPath), { recursive: true });
|
|
await writeFileAtomic(
|
|
transactionPath,
|
|
serializeClientConfiguration(document, format),
|
|
{
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
},
|
|
);
|
|
} catch {
|
|
return { status: "failed", reason: "write" };
|
|
}
|
|
try {
|
|
const readback = parseClientConfiguration(
|
|
await readFile(transactionPath, "utf8"),
|
|
format,
|
|
);
|
|
if (
|
|
!isDeepStrictEqual(
|
|
readback.servers[PRODUCT_IDENTITY.mcpServerKey],
|
|
desired,
|
|
)
|
|
) {
|
|
await restoreConfig(transactionPath, original);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
} catch {
|
|
await restoreConfig(transactionPath, original);
|
|
return { status: "failed", reason: "readback" };
|
|
}
|
|
return {
|
|
status: "configured",
|
|
...(backupPath === undefined ? {} : { backupPath }),
|
|
};
|
|
};
|
|
|
|
/** Determine whether an existing client configuration matches the desired registration. */
|
|
export const clientConfigurationAligned = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
): Promise<boolean> => {
|
|
const desired = clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
);
|
|
try {
|
|
const original = await readFile(client.configPath, "utf8");
|
|
const { servers } = parseClientConfiguration(original, client.format);
|
|
return isDeepStrictEqual(servers[PRODUCT_IDENTITY.mcpServerKey], desired);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Preflight one client configuration before setup presents any mutations. */
|
|
export const inspectClientConfiguration = async (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
): Promise<ClientConfigurationInspection> => {
|
|
if (client.format === "unsupported") return { status: "already_current" };
|
|
const transactionPath = await resolveClientConfigTransactionPath(
|
|
client.configPath,
|
|
);
|
|
if (transactionPath === undefined)
|
|
return {
|
|
status: "invalid",
|
|
remediation:
|
|
"The configuration path is unsafe or unresolved. Check ownership and symbolic links before rerunning setup.",
|
|
};
|
|
let original: string;
|
|
try {
|
|
original = await readFile(transactionPath, "utf8");
|
|
} catch (cause: unknown) {
|
|
if (isMissing(cause)) return { status: "create" };
|
|
return {
|
|
status: "invalid",
|
|
remediation:
|
|
"The configuration file could not be read. Check its permissions before rerunning setup.",
|
|
};
|
|
}
|
|
const desired = clientConfigurationDesired(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
);
|
|
try {
|
|
const { servers } = parseClientConfiguration(original, client.format);
|
|
if (isDeepStrictEqual(servers[PRODUCT_IDENTITY.mcpServerKey], desired))
|
|
return { status: "already_current" };
|
|
} catch {
|
|
return {
|
|
status: "invalid",
|
|
remediation:
|
|
"The existing configuration is malformed. Repair it before setup applies any other changes.",
|
|
};
|
|
}
|
|
return {
|
|
status: "update",
|
|
backupPath: `${client.configPath}.rea.backup`,
|
|
};
|
|
};
|
|
|
|
const isMissing = (cause: unknown): boolean =>
|
|
cause instanceof Error && "code" in cause && cause.code === "ENOENT";
|
|
const preserveConfigBackup = async (
|
|
source: string,
|
|
destination: string,
|
|
): Promise<boolean> => {
|
|
try {
|
|
await copyFile(source, destination, fsConstants.COPYFILE_EXCL);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
|
}
|
|
};
|
|
|
|
const restoreConfig = async (
|
|
path: string,
|
|
original: string | undefined,
|
|
): Promise<void> => {
|
|
try {
|
|
if (original === undefined) await rm(path, { force: true });
|
|
else await writeFile(path, original, { encoding: "utf8", mode: 0o600 });
|
|
} catch {
|
|
// The backup remains available for the remediation reported by setup.
|
|
}
|
|
};
|
|
|
|
const clientConfigurationDesired = (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
) => {
|
|
const environment = Object.fromEntries(
|
|
Object.entries(providerEnvironment).sort(([left], [right]) =>
|
|
left.localeCompare(right),
|
|
),
|
|
);
|
|
return {
|
|
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
|
args: command.slice(1),
|
|
...(client.name === "codex"
|
|
? {
|
|
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
|
}
|
|
: {}),
|
|
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
|
};
|
|
};
|