Files
rea/src/application/SetupPlan.ts
T

454 lines
17 KiB
TypeScript

import { SetupSkillInstallError, type SkillDestination } from "./SetupSkill.js";
import { classifyClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
import type { LinuxPackageFamily } from "./HopperReleaseFeed.js";
import {
linuxHopperInstallDisclosure,
LINUX_HOPPER_SETUP_HOSTS,
type LinuxDistribution,
} from "./LinuxHopper.js";
import { macHopperInstallDisclosure } from "./MacHopper.js";
import { macHopperUserApplicationPath } from "./MacHopperPaths.js";
import type {
ClientConfigurationInspection,
SetupAction,
SetupClientState,
SetupHost,
SetupProviderEnvironment,
} from "./SetupTypes.js";
import {
appDataRedirection,
registrationPersistenceDetail,
type SetupClient,
} from "./SupportedClients.js";
import { redirectedWriteBlocker } from "./MsixPackageStorage.js";
import type { DoctorReport } from "./Doctor.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
/** Whether automated Hopper installation can run on this host, and why not. */
export type HopperInstallEligibility =
| { readonly supported: true }
| { readonly supported: false; readonly remediation: string };
/** Read-only setup facts shared by client selection and preflight. */
export interface SetupDiscovery {
readonly initialDoctor: Awaited<ReturnType<SetupHost["doctor"]>>;
readonly installHopper: boolean;
readonly hopperInstall: HopperInstallEligibility;
readonly clients: readonly SetupClient[];
readonly detectedClientIds: readonly string[];
readonly defaultClientIds: readonly string[];
readonly clientStates: readonly SetupClientState[];
readonly linuxPackageFamily?: LinuxPackageFamily;
}
/** Discover supported clients and current registrations without mutation. */
export const discoverSetupState = async (input: {
readonly host: SetupHost;
readonly providerConfigured: boolean;
readonly forceHopperInstall: boolean;
readonly hopperLauncherConfigured?: boolean;
readonly proposeHopper: boolean;
readonly initialDoctor: DoctorReport;
}): Promise<SetupDiscovery> => {
const { initialDoctor } = input;
const linuxHopperRepairNeeded =
input.hopperLauncherConfigured !== true &&
initialDoctor.checks.some(
({ name, ok, detail }) =>
!ok &&
(name === "hopper-demo-runtime" ||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
);
const linuxDistribution =
input.host.platform === "linux"
? await input.host.linuxDistribution()
: undefined;
const hopperInstall = hopperInstallEligibility(
input.host.platform,
input.host.platform === "darwin"
? await input.host.macosVersion()
: undefined,
linuxDistribution,
);
const installHopper =
input.forceHopperInstall ||
(hopperInstall.supported &&
input.proposeHopper &&
(!input.providerConfigured || linuxHopperRepairNeeded));
const [clients, detectedClients] = await Promise.all([
input.host.supportedClients(),
input.host.detectedClients(),
]);
const detectedIds = new Set(detectedClients.map(({ name }) => name));
const registrations = initialDoctor.identity?.registrations ?? [];
const registrationByClient = new Map(
registrations.map((registration) => [registration.client, registration]),
);
const clientStates = clients.map((client): SetupClientState => {
const registration = registrationByClient.get(client.name);
const commandIdentity =
registration === undefined
? undefined
: classifyClientRegistrationCommand(
registration.command,
CURRENT_ENTRYPOINT_PATH,
);
const ownedRegistration =
(registration?.state === "aligned" || registration?.state === "stale") &&
commandIdentity === "owned";
const configured = ownedRegistration;
const status: SetupClientState["status"] =
registration?.state === "invalid"
? "invalid"
: registration?.state === "unknown" || commandIdentity === "unknown"
? "unknown"
: ownedRegistration && registration.state === "aligned"
? "configured"
: ownedRegistration && registration.state === "stale"
? "needs_configuration"
: detectedIds.has(client.name)
? "needs_configuration"
: "missing";
return {
client,
detected: detectedIds.has(client.name),
configured,
status,
};
});
const defaultClientIds = registrations
.filter(
(registration) =>
(registration.state === "aligned" || registration.state === "stale") &&
classifyClientRegistrationCommand(
registration.command,
CURRENT_ENTRYPOINT_PATH,
) === "owned",
)
.map(({ client }) => client);
return {
initialDoctor,
installHopper,
hopperInstall,
clients,
detectedClientIds: [...detectedIds],
defaultClientIds,
clientStates,
...(linuxDistribution?.packageFamily === undefined
? {}
: { linuxPackageFamily: linuxDistribution.packageFamily }),
};
};
/** Preflight exactly the selected clients and produce their concrete actions. */
export const planSetupActions = async (input: {
readonly discovery: SetupDiscovery;
readonly host: SetupHost;
readonly providerEnvironment: SetupProviderEnvironment;
readonly command: readonly string[];
readonly clientIds: readonly string[];
readonly skillClientIds: readonly string[];
readonly installSkill: boolean;
}): Promise<{
readonly plannedActions: readonly SetupAction[];
readonly selectedClients: readonly SetupClient[];
readonly clientsToConfigure: readonly SetupClient[];
readonly blocker?: string;
}> => {
const byId = new Map(
input.discovery.clients.map((client) => [client.name, client]),
);
const unknownClient = input.clientIds.find((id) => !byId.has(id));
if (unknownClient !== undefined) {
return {
plannedActions: [],
selectedClients: [],
clientsToConfigure: [],
blocker: `Unsupported setup client: ${unknownClient}.`,
};
}
const selectedClients = input.clientIds.flatMap((id) => {
const client = byId.get(id);
return client === undefined ? [] : [client];
});
const hopperCache = input.host.linuxHopperReleaseCache;
if (input.discovery.installHopper && hopperCache?.ok === false)
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: hopperCache.error.message,
};
if (
selectedClients.length > 0 &&
input.host.registrationCommandBlocker !== undefined
)
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: input.host.registrationCommandBlocker,
};
const inspections = await Promise.all(
selectedClients.map(async (client) => ({
client,
inspection: await input.host.inspectClientConfiguration(
client,
input.providerEnvironment,
input.command,
),
})),
);
const invalid = inspections.find(
({ inspection }) => inspection.status === "invalid",
);
if (invalid?.inspection.status === "invalid") {
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`,
};
}
let skillDestinations: readonly SkillDestination[] = [];
try {
if (input.installSkill)
skillDestinations = input.host.skillDestinations(input.skillClientIds);
} catch (cause: unknown) {
if (!(cause instanceof SetupSkillInstallError)) throw cause;
return {
plannedActions: [],
selectedClients,
clientsToConfigure: [],
blocker: cause.remediation,
};
}
const clientPlans = plannedClientWrites(
inspections,
input.discovery.installHopper,
);
const plannedActions = setupPlan({
platform: input.host.platform,
homeDirectory: input.host.homeDirectory,
...(hopperCache?.ok === true
? { hopperCacheDirectory: hopperCache.value }
: {}),
installHopper: input.discovery.installHopper,
installSkill: input.installSkill,
skillClientIds: input.skillClientIds,
skillDestinations,
clients: clientPlans,
providerEnvironment: input.providerEnvironment,
...(input.discovery.linuxPackageFamily === undefined
? {}
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
command: input.command,
});
const blocker =
(clientPlans.length > 0
? input.host.registrationCommandBlocker
: undefined) ??
redirectedWriteBlocker(
appDataRedirection(input.discovery.clients),
plannedActions
.filter(({ kind }) => kind !== "install_hopper")
.map(({ target }) => target),
"setup",
);
return {
plannedActions,
selectedClients,
clientsToConfigure: selectedClients.filter((client) =>
clientPlans.some(({ client: planned }) => planned.name === client.name),
),
...(blocker === undefined ? {} : { blocker }),
};
};
/** Selected clients whose registration setup writes, with the disclosed operation. */
const plannedClientWrites = (
inspections: readonly {
readonly client: SetupClient;
readonly inspection: ClientConfigurationInspection;
}[],
installHopper: boolean,
) =>
inspections.flatMap(({ client, inspection }) =>
client.format !== "unsupported" &&
(inspection.status === "create" ||
inspection.status === "update" ||
(installHopper && inspection.status === "already_current"))
? [
{
client,
operation:
inspection.status === "already_current"
? ("update" as const)
: inspection.status,
...(inspection.status !== "already_current" &&
inspection.backupPath !== undefined
? { backupPath: inspection.backupPath }
: {}),
},
]
: [],
);
/** Describe every selected setup mutation before user approval. */
const setupPlan = (input: {
readonly platform: NodeJS.Platform;
readonly homeDirectory: string;
readonly hopperCacheDirectory?: string;
readonly installHopper: boolean;
readonly installSkill: boolean;
readonly skillClientIds: readonly string[];
readonly skillDestinations: readonly SkillDestination[];
readonly clients: readonly {
readonly client: SetupClient;
readonly operation: "create" | "update";
readonly backupPath?: string;
}[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly linuxPackageFamily?: LinuxPackageFamily;
readonly command: readonly string[];
}): readonly SetupAction[] => [
...(input.installHopper
? [
{
id: "install_hopper",
kind: "install_hopper" as const,
label: "Hopper deep-analysis provider",
target:
input.platform === "darwin"
? macHopperUserApplicationPath(input.homeDirectory)
: "system package manager",
detail:
input.platform === "linux"
? `Download the current official Hopper demo, verify its PGP publisher and launcher bytes, and retain archive, public-key.asc and release.json under ${input.hopperCacheDirectory ?? `${input.homeDirectory}/.cache/rea/hopper-releases`}/<launcher-sha256> for offline verification. Preserve replaced damaged evidence beside it as <launcher-sha256>.backup-<id>. Install Hopper plus its Xvfb demo-session dependencies${input.linuxPackageFamily === "deb" ? ", including the shared library matching the distribution Python interpreter" : ""}. REA uses a private display and selects Hopper's offered demo mode for each analysis session.`
: "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
external: true,
operation: "install" as const,
...(input.platform === "linux"
? input.linuxPackageFamily === undefined
? {}
: linuxDisclosure(input.linuxPackageFamily)
: {
networkOrigins: macHopperInstallDisclosure.networkOrigins,
commands: macHopperInstallDisclosure.commands,
}),
},
]
: []),
...input.clients
.filter(({ client }) => client.format !== "unsupported")
.map(({ client, operation, backupPath }): SetupAction => ({
id: `configure_client:${client.name}`,
kind: "configure_client",
label: client.displayName ?? client.name,
target: client.configPath,
detail: clientConfigurationDetail(
client,
input.command,
input.providerEnvironment,
input.installHopper,
),
external: false,
operation,
...(backupPath === undefined ? {} : { backupPath }),
})),
...(input.installSkill
? input.skillDestinations.map(({ client, path }): SetupAction => ({
id: client === "shared" ? "install_skill" : `install_skill:${client}`,
kind: "install_skill",
label:
client === "claude_code"
? "REA skill for Claude Code"
: "REA reverse-engineering skill",
target: path,
detail:
client === "claude_code"
? "Install or update the REA skill in Claude Code's personal skill directory."
: "Install or update the bundled REA reverse-engineering skill and on-demand references.",
external: false,
operation: "install",
}))
: []),
];
const linuxDisclosure = (family: LinuxPackageFamily) => {
const disclosure = linuxHopperInstallDisclosure(
family,
process.getuid?.() === 0,
);
return {
networkOrigins: disclosure.networkOrigins,
commands: disclosure.commands,
integrity: disclosure.integrity,
};
};
/** Refuse a selected Hopper installation this host cannot run, before mutation. */
export const hopperInstallRefusal = (
discovery: Pick<SetupDiscovery, "installHopper" | "hopperInstall">,
): string | undefined =>
discovery.installHopper && !discovery.hopperInstall.supported
? discovery.hopperInstall.remediation
: undefined;
/**
* Decide automated Hopper installation once from the host facts read during
* discovery, so proposals, explicit requests, and recovery advice agree. An
* unknown or unparseable macOS version and an unidentified Linux distribution
* are not eligible.
*/
const hopperInstallEligibility = (
platform: NodeJS.Platform,
macosVersion: string | undefined,
linuxDistribution: LinuxDistribution | undefined,
): HopperInstallEligibility => {
if (platform === "darwin") {
const major =
macosVersion === undefined
? undefined
: /^(\d+)(?:\.|$)/u.exec(macosVersion)?.[1];
return major !== undefined && Number(major) >= 12
? { supported: true }
: { supported: false, remediation: "Upgrade to macOS 12 or newer." };
}
if (platform === "linux")
return linuxDistribution?.hopperSetupSupported === true
? { supported: true }
: {
supported: false,
remediation: `Automated Hopper setup requires ${LINUX_HOPPER_SETUP_HOSTS}; configure an existing supported provider instead.`,
};
return {
supported: false,
remediation:
"REA supports Hopper on macOS and selected x86_64 Linux distributions.",
};
};
const clientConfigurationDetail = (
client: SetupClient,
command: readonly string[],
environment: SetupProviderEnvironment,
installHopper: boolean,
): string => {
const entries = Object.entries(environment)
.sort(([left], [right]) => compareUnicodeCodePoints(left, right))
.map(([name, value]) => `${name}=${value}`);
const environmentDetail =
entries.length === 0
? " No additional provider environment variables. Existing server environment overrides are retained."
: ` Detected provider environment: ${entries.join(", ")}. Existing server environment overrides are retained; detected values replace matching keys.`;
const hopperDetail = installHopper
? environment.HOPPER_LAUNCHER_PATH === undefined
? " If this plan installs Hopper, REA will add HOPPER_LAUNCHER_PATH using the launcher path reported by that installation."
: " The HOPPER_LAUNCHER_PATH value shown above is the current fallback; if this plan installs Hopper, REA will replace it with the launcher path reported by that installation."
: "";
const persistence = registrationPersistenceDetail(client);
return `Add the REA MCP registration for ${client.displayName ?? client.name}; preserve unrelated configuration. The backup path reflects the configuration inspected for this plan; if that file changes before execution, setup will select a recovery path for its current contents. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}${persistence === undefined ? "" : ` ${persistence}`}`;
};