mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
460 lines
16 KiB
TypeScript
460 lines
16 KiB
TypeScript
import { access } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
|
import { ConfigurationError } from "../domain/configurationErrors.js";
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import {
|
|
invokingPackageRunner,
|
|
packageRunnerRegistrationCommand,
|
|
type PackageRunner,
|
|
} from "./ClientRegistrationIdentity.js";
|
|
import {
|
|
SUPPORTED_NODE_VERSION_PROSE,
|
|
supportsNodeVersion,
|
|
} from "../domain/runtimeVersion.js";
|
|
import { runDoctor, systemDoctorHost, type DoctorHost } from "./Doctor.js";
|
|
import {
|
|
installLinuxHopper,
|
|
LinuxHopperTemporaryCleanupOwner,
|
|
readLinuxDistribution,
|
|
systemLinuxHopperInstallHost,
|
|
} from "./LinuxHopper.js";
|
|
import { installMacHopper, systemMacHopperInstallHost } from "./MacHopper.js";
|
|
import {
|
|
clientEvidencePaths,
|
|
supportedClients,
|
|
type SetupClient,
|
|
} from "./SupportedClients.js";
|
|
import {
|
|
skillDestinations,
|
|
canonicalSkillNeedsInstall,
|
|
installCanonicalSkill,
|
|
validateCanonicalSkillDirectories,
|
|
} from "./SetupSkill.js";
|
|
import {
|
|
clientConfigurationAligned,
|
|
ClientConfigurationCleanupRollbackFailure,
|
|
configureClientConfiguration,
|
|
inspectClientConfiguration,
|
|
} from "./SetupClientConfiguration.js";
|
|
import { failedConfigurationMessage } from "./SetupClients.js";
|
|
import { setupInstallFailure } from "./SetupInstallFailure.js";
|
|
import {
|
|
RegularFileCleanupFailure,
|
|
retryRegularFileCleanup,
|
|
} from "./RegularFileRead.js";
|
|
import {
|
|
explicitSetupRegistrationEnvironment,
|
|
providerRegistrationEnvironment,
|
|
} from "./SetupRegistrationEnvironment.js";
|
|
import {
|
|
msixPackagesDirectory,
|
|
packagePrivateLauncherBlocker,
|
|
} from "./MsixPackageStorage.js";
|
|
import type {
|
|
SetupHost,
|
|
SetupInitialState,
|
|
SetupProviderEnvironment,
|
|
} from "./SetupTypes.js";
|
|
import type { DoctorScope } from "./Doctor.js";
|
|
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
|
|
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
|
|
|
/** Resolve the executable and arguments used in a managed MCP registration. */
|
|
export const setupRegistrationCommand = (
|
|
platform: NodeJS.Platform,
|
|
packageRunner: PackageRunner | undefined,
|
|
entrypointPath: string | undefined,
|
|
): readonly string[] => {
|
|
if (packageRunner !== undefined)
|
|
return packageRunnerRegistrationCommand(packageRunner, platform);
|
|
if (entrypointPath === undefined || entrypointPath.length === 0) return [];
|
|
return platform === "win32"
|
|
? [process.execPath, entrypointPath, "mcp"]
|
|
: [entrypointPath, "mcp"];
|
|
};
|
|
|
|
/**
|
|
* The launcher setup registers, and why it must not be registered when it
|
|
* exists only inside one MSIX package's private storage.
|
|
*/
|
|
const setupRegistration = (
|
|
platform: NodeJS.Platform,
|
|
homeDirectory: string,
|
|
environment: Readonly<NodeJS.ProcessEnv>,
|
|
): Pick<SetupHost, "registrationCommand" | "registrationCommandBlocker"> => {
|
|
const registrationCommand = setupRegistrationCommand(
|
|
platform,
|
|
invokingPackageRunner(environment, platform),
|
|
CURRENT_ENTRYPOINT_PATH,
|
|
);
|
|
const registrationCommandBlocker =
|
|
registrationCommand.length === 0
|
|
? "REA could not determine the current executable entrypoint. Run setup through the installed REA CLI or a supported package runner before configuring clients."
|
|
: platform === "win32"
|
|
? packagePrivateLauncherBlocker(
|
|
registrationCommand,
|
|
msixPackagesDirectory(homeDirectory, environment),
|
|
)
|
|
: undefined;
|
|
return registrationCommandBlocker === undefined
|
|
? { registrationCommand }
|
|
: { registrationCommand, registrationCommandBlocker };
|
|
};
|
|
|
|
export const filterClientsNeedingConfigure = async (
|
|
host: SetupHost,
|
|
detectedClients: readonly SetupClient[],
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
): Promise<readonly SetupClient[]> => {
|
|
const needs = await Promise.all(
|
|
detectedClients.map((client) =>
|
|
host.clientNeedsConfigure(client, providerEnvironment, command),
|
|
),
|
|
);
|
|
return detectedClients.filter((_, index) => needs[index]);
|
|
};
|
|
|
|
export const hostRemediation = async (
|
|
host: SetupHost,
|
|
installHopper: boolean,
|
|
): Promise<string | undefined> => {
|
|
if (!supportsNodeVersion(host.nodeVersion))
|
|
return `Install ${SUPPORTED_NODE_VERSION_PROSE} and rerun setup.`;
|
|
if (!installHopper) return undefined;
|
|
if (host.platform !== "darwin" && host.platform !== "linux")
|
|
return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
|
|
if (host.platform === "darwin") {
|
|
const version = await host.macosVersion();
|
|
return version === undefined || major(version) < 12
|
|
? "Upgrade to macOS 12 or newer."
|
|
: undefined;
|
|
}
|
|
if ((await host.linuxDistribution())?.hopperSetupSupported === true)
|
|
return undefined;
|
|
return "Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, Nobara 44+, 64-bit Arch Linux, CachyOS, and Omarchy; configure an existing supported provider instead.";
|
|
};
|
|
|
|
/** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
|
|
export const systemSetupHost = (
|
|
selectedDoctorHost: DoctorHost | undefined = undefined,
|
|
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
): SetupHost => {
|
|
const doctorHost = selectedDoctorHost ?? systemDoctorHost({ environment });
|
|
const platform = doctorHost.platform;
|
|
const { homeDirectory } = doctorHost;
|
|
const macHopperHost =
|
|
platform === "darwin"
|
|
? systemMacHopperInstallHost(homeDirectory, environment)
|
|
: undefined;
|
|
const resources = new ArtifactResourceScope();
|
|
const linuxHopperHost = systemLinuxHopperInstallHost(environment);
|
|
const pendingLinuxHopperCleanup = new LinuxHopperTemporaryCleanupOwner();
|
|
return {
|
|
platform,
|
|
homeDirectory,
|
|
skillDestinations: (clientIds) =>
|
|
skillDestinations(homeDirectory, clientIds, environment, platform),
|
|
...setupRegistration(platform, homeDirectory, environment),
|
|
nodeVersion: process.versions.node,
|
|
macosVersion: () => doctorHost.macosVersion(),
|
|
linuxDistribution: readLinuxDistribution,
|
|
close: () =>
|
|
closeSetupResources(resources, macHopperHost, doctorHost, () =>
|
|
pendingLinuxHopperCleanup.retry(linuxHopperHost.cleanup),
|
|
),
|
|
initialSetupState: async (
|
|
scope?: DoctorScope,
|
|
): Promise<SetupInitialState> => {
|
|
const diagnosis = await runDoctor(undefined, doctorHost, scope);
|
|
const providerEnvironment: SetupProviderEnvironment = {
|
|
...providerRegistrationEnvironment(diagnosis.providerInspections ?? []),
|
|
...doctorHost.providerRegistrationEnvironment?.(),
|
|
...explicitSetupRegistrationEnvironment(environment),
|
|
...(diagnosis.hopperPath === undefined
|
|
? {}
|
|
: { HOPPER_LAUNCHER_PATH: diagnosis.hopperPath }),
|
|
};
|
|
const requestedProvider = analysisProviderSelectorSchema.safeParse(
|
|
environment.REA_ANALYSIS_PROVIDER,
|
|
);
|
|
return {
|
|
...(diagnosis.hopperPath === undefined
|
|
? {}
|
|
: { hopperPath: diagnosis.hopperPath }),
|
|
...(typeof environment.HOPPER_LAUNCHER_PATH === "string" &&
|
|
environment.HOPPER_LAUNCHER_PATH.trim() !== "" &&
|
|
diagnosis.hopperPath === environment.HOPPER_LAUNCHER_PATH
|
|
? { hopperLauncherConfigured: true }
|
|
: {}),
|
|
providerConfigured:
|
|
diagnosis.hopperPath !== undefined ||
|
|
diagnosis.providerInspections?.some(
|
|
({ configured }) => configured,
|
|
) === true ||
|
|
providerEnvironment.REA_JEB_MCP_URL !== undefined ||
|
|
(requestedProvider.success && requestedProvider.data !== "auto"),
|
|
providerEnvironment,
|
|
doctor: diagnosis,
|
|
};
|
|
},
|
|
installHopper: async (replaceExisting) => {
|
|
if (platform === "linux") {
|
|
const result = await installLinuxHopper(linuxHopperHost);
|
|
if (result.cleanupFailure !== undefined)
|
|
pendingLinuxHopperCleanup.retain(result.cleanupFailure);
|
|
return result.status === "installed"
|
|
? { status: "installed", launcherPath: result.launcherPath }
|
|
: setupInstallFailure(result.reason, result.packageFailure);
|
|
}
|
|
if (macHopperHost === undefined)
|
|
return setupInstallFailure("unsupported_host");
|
|
const result = await installMacHopper({ replaceExisting }, macHopperHost);
|
|
if (result.status === "installed")
|
|
return {
|
|
status: "installed",
|
|
launcherPath: result.launcherPath,
|
|
...(result.openFailure === undefined
|
|
? {}
|
|
: {
|
|
warning: `Hopper was installed, but macOS could not open it: ${result.openFailure}. Open ${result.launcherPath} manually to complete first-run setup.`,
|
|
}),
|
|
...(result.cleanupFailure === undefined
|
|
? {}
|
|
: { cleanupFailure: result.cleanupFailure }),
|
|
};
|
|
const failure = setupInstallFailure(result.reason);
|
|
const diagnostic = setupInstallerDiagnostic(result.diagnostic);
|
|
return {
|
|
...failure,
|
|
remediation: [
|
|
failure.remediation,
|
|
...(diagnostic === undefined
|
|
? []
|
|
: [`Installer detail: ${diagnostic}`]),
|
|
...(result.cleanupFailure === undefined
|
|
? []
|
|
: [result.cleanupFailure]),
|
|
].join(" "),
|
|
};
|
|
},
|
|
detectedClients: () => detectClients(homeDirectory, platform, environment),
|
|
supportedClients: () =>
|
|
Promise.resolve(supportedClients(homeDirectory, platform, environment)),
|
|
...clientConfigurationOperations(
|
|
resources,
|
|
doctorHost.resolveProviderRequestBudget,
|
|
),
|
|
skillNeedsInstall: (clientIds) =>
|
|
canonicalSkillNeedsInstall(
|
|
homeDirectory,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
resources,
|
|
),
|
|
validateSkillDirectories: (clientIds) =>
|
|
validateCanonicalSkillDirectories(
|
|
homeDirectory,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
),
|
|
installSkill: (clientIds) =>
|
|
installCanonicalSkill(
|
|
homeDirectory,
|
|
clientIds,
|
|
environment,
|
|
platform,
|
|
undefined,
|
|
resources,
|
|
),
|
|
doctor: (scope) => runDoctor(undefined, doctorHost, scope),
|
|
};
|
|
};
|
|
|
|
const setupInstallerDiagnostic = (
|
|
value: string | undefined,
|
|
): string | undefined => {
|
|
if (value === undefined) return undefined;
|
|
const normalized = value
|
|
.replace(/\u001b(?:\[[0-9;?]*[ -/]*[@-~]|\([0-9A-Za-z]|[@-Z\\^_])/gu, "")
|
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "")
|
|
.replace(/\s+/gu, " ")
|
|
.trim();
|
|
if (normalized === "") return undefined;
|
|
return normalized.length <= 400
|
|
? normalized
|
|
: `${normalized.slice(0, 397).trimEnd()}...`;
|
|
};
|
|
|
|
const clientConfigurationOperations = (
|
|
resources: ArtifactResourceScope,
|
|
resolveProviderRequestBudget: DoctorHost["resolveProviderRequestBudget"],
|
|
) => ({
|
|
configureClient: (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
) =>
|
|
runClientConfigurationOperation(resources, async () =>
|
|
client.format === "unsupported"
|
|
? { status: "skipped" as const }
|
|
: configureClientConfiguration(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
resolveProviderRequestBudget,
|
|
),
|
|
),
|
|
clientNeedsConfigure: (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
) =>
|
|
runClientConfigurationOperation(resources, async () =>
|
|
client.format === "unsupported"
|
|
? false
|
|
: !(await clientConfigurationAligned(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
resolveProviderRequestBudget,
|
|
)),
|
|
),
|
|
inspectClientConfiguration: (
|
|
client: SetupClient,
|
|
providerEnvironment: SetupProviderEnvironment,
|
|
command: readonly string[],
|
|
) =>
|
|
runClientConfigurationOperation(resources, () =>
|
|
inspectClientConfiguration(
|
|
client,
|
|
providerEnvironment,
|
|
command,
|
|
resolveProviderRequestBudget,
|
|
),
|
|
),
|
|
});
|
|
|
|
const closeSetupResources = async (
|
|
resources: ArtifactResourceScope,
|
|
macHopperHost: ReturnType<typeof systemMacHopperInstallHost> | undefined,
|
|
doctorHost: DoctorHost,
|
|
retryLinuxHopperCleanup: () => Promise<string | undefined>,
|
|
): Promise<string | undefined> => {
|
|
const [resourceCleanup, hopperCleanup, doctorCleanup, linuxCleanup] =
|
|
await Promise.allSettled([
|
|
resources.close(),
|
|
Promise.resolve().then(() => macHopperHost?.close()),
|
|
Promise.resolve().then(() => doctorHost.close?.()),
|
|
retryLinuxHopperCleanup(),
|
|
]);
|
|
const failures: string[] = [];
|
|
if (hopperCleanup.status === "rejected")
|
|
failures.push(
|
|
`Hopper host cleanup failed: ${errorMessage(hopperCleanup.reason)}`,
|
|
);
|
|
else if (hopperCleanup.value !== undefined)
|
|
failures.push(hopperCleanup.value);
|
|
if (doctorCleanup.status === "rejected")
|
|
failures.push(
|
|
`Doctor host cleanup failed: ${errorMessage(doctorCleanup.reason)}`,
|
|
);
|
|
else if (doctorCleanup.value !== undefined)
|
|
failures.push(doctorCleanup.value);
|
|
if (linuxCleanup.status === "rejected")
|
|
failures.push(
|
|
`Linux Hopper cleanup retry failed: ${errorMessage(linuxCleanup.reason)}`,
|
|
);
|
|
else if (linuxCleanup.value !== undefined) failures.push(linuxCleanup.value);
|
|
if (resourceCleanup.status === "rejected")
|
|
failures.push(errorMessage(resourceCleanup.reason));
|
|
return failures.length === 0 ? undefined : failures.join("; ");
|
|
};
|
|
|
|
const runClientConfigurationOperation = async <Value>(
|
|
resources: ArtifactResourceScope,
|
|
operation: () => Promise<Value>,
|
|
): Promise<Value> => {
|
|
try {
|
|
return await resources.run(async () => {
|
|
try {
|
|
return await operation();
|
|
} catch (cause: unknown) {
|
|
if (!(cause instanceof RegularFileCleanupFailure)) throw cause;
|
|
const cleanup = await retryRegularFileCleanup(cause, resources);
|
|
const primary =
|
|
cleanup.outcome.kind === "failed"
|
|
? cleanup.outcome.cause
|
|
: cause.cleanupCause;
|
|
const rollbackMessage =
|
|
cause instanceof ClientConfigurationCleanupRollbackFailure
|
|
? ` ${failedConfigurationMessage(cause.configurationFailure)}`
|
|
: "";
|
|
throw configurationCleanupError(
|
|
`Could not safely access client configuration at ${cause.path}: ${errorMessage(primary)}.${rollbackMessage}`,
|
|
cause,
|
|
cleanup.cleanup,
|
|
);
|
|
}
|
|
});
|
|
} catch (cause: unknown) {
|
|
if (cause instanceof ArtifactReaderFailure)
|
|
throw configurationCleanupError(cause.message, cause, cause.cleanup);
|
|
throw cause;
|
|
}
|
|
};
|
|
|
|
const configurationCleanupError = (
|
|
message: string,
|
|
cause: unknown,
|
|
cleanup:
|
|
| { readonly reason: string; readonly resources: readonly string[] }
|
|
| undefined,
|
|
): ConfigurationError =>
|
|
new ConfigurationError(message, {
|
|
cause,
|
|
settings: [{ setting: "client configuration", constraint: message }],
|
|
...(cleanup === undefined ? {} : { cleanup }),
|
|
});
|
|
|
|
const errorMessage = (cause: unknown): string =>
|
|
cause instanceof Error ? cause.message : String(cause);
|
|
|
|
/** Detect supported agents from their config files or stable installation markers. */
|
|
export const detectClients = async (
|
|
home: string,
|
|
platform: NodeJS.Platform = process.platform,
|
|
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
|
): Promise<readonly SetupClient[]> => {
|
|
const detected: SetupClient[] = [];
|
|
for (const candidate of supportedClients(home, platform, environment)) {
|
|
if (candidate.configPathError !== undefined) continue;
|
|
if (await clientEvidencePresent(candidate)) detected.push(candidate);
|
|
}
|
|
return detected;
|
|
};
|
|
|
|
const major = (version: string): number =>
|
|
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
|
const clientEvidencePresent = async (client: SetupClient): Promise<boolean> => {
|
|
for (const path of clientEvidencePaths(client))
|
|
if (await exists(path)) return true;
|
|
return false;
|
|
};
|
|
|
|
const exists = async (path: string): Promise<boolean> => {
|
|
try {
|
|
await access(path);
|
|
return true;
|
|
} catch (cause: unknown) {
|
|
// best-effort cleanup: optional host probing; absence means unavailable.
|
|
void cause;
|
|
return false;
|
|
}
|
|
};
|