* fix: order remaining outputs independently of host locale
String.prototype.localeCompare without a pinned collation follows the
host's ICU default locale, so the same input could be ordered differently
on different machines. 614fb768 moved canonical domain projections to
compareUnicodeCodePoints; provider, application, catalog, and setup
orderings still used locale collation.
Order every remaining source site by Unicode code point, including Mach-O
symbols, firmware extraction paths, ASAR entries (previously pinned to
"en" collation), evidence ID sets, browser and Electron target, script,
and resource inventories, reconstruction obligations, capability and
provider listings, and setup environment details.
The current tool and prompt catalog names order identically under both
comparators, so the catalog identity digest is unchanged.
* fix(artifacts): order ASAR entries by their portable path
@electron/asar lists host-spelled paths, so code-point order over the raw
spelling still differed by host: "a\b" sorts after "a0" on Windows while
"a/b" sorts before it on POSIX. Pair each listing with its portable
archive path once, order by that path, and keep the host spelling for
@electron/asar calls and adapter keys.
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
import { join } from "node:path";
|
|
|
|
import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
|
import {
|
|
linuxHopperInstallDisclosure,
|
|
type LinuxPackageFamily,
|
|
} from "./LinuxHopper.js";
|
|
import type { SkillDestination } from "./SetupSkill.js";
|
|
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
|
import type {
|
|
SetupAction,
|
|
SetupClientState,
|
|
SetupHost,
|
|
SetupProviderEnvironment,
|
|
} from "./SetupTypes.js";
|
|
import type { SetupClient } from "./SupportedClients.js";
|
|
import type { DoctorReport } from "./Doctor.js";
|
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|
|
|
/** Read-only setup facts shared by client selection and preflight. */
|
|
export interface SetupDiscovery {
|
|
readonly initialDoctor: Awaited<ReturnType<SetupHost["doctor"]>>;
|
|
readonly installHopper: boolean;
|
|
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 providerEnvironment: SetupProviderEnvironment;
|
|
readonly forceHopperInstall: boolean;
|
|
readonly proposeHopper: boolean;
|
|
readonly initialDoctor: DoctorReport;
|
|
}): Promise<SetupDiscovery> => {
|
|
const { initialDoctor } = input;
|
|
const linuxHopperRepairNeeded = 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 hopperInstallSupported =
|
|
(input.host.platform === "darwin" &&
|
|
majorVersion((await input.host.macosVersion()) ?? "0") >= 12) ||
|
|
(input.host.platform === "linux" && linuxDistribution?.supported === true);
|
|
const installHopper =
|
|
input.forceHopperInstall ||
|
|
(hopperInstallSupported &&
|
|
input.proposeHopper &&
|
|
(Object.keys(input.providerEnvironment).length === 0 ||
|
|
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 ownedRegistration =
|
|
(registration?.state === "aligned" || registration?.state === "stale") &&
|
|
isOwnedClientRegistrationCommand(registration.command);
|
|
const configured = ownedRegistration;
|
|
const status: SetupClientState["status"] =
|
|
registration?.state === "invalid"
|
|
? "invalid"
|
|
: 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") &&
|
|
isOwnedClientRegistrationCommand(registration.command),
|
|
)
|
|
.map(({ client }) => client);
|
|
return {
|
|
initialDoctor,
|
|
installHopper,
|
|
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 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}`,
|
|
};
|
|
}
|
|
const clientPlans = inspections.flatMap(({ client, inspection }) =>
|
|
client.format !== "unsupported" &&
|
|
(inspection.status === "create" ||
|
|
inspection.status === "update" ||
|
|
(input.discovery.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 }
|
|
: {}),
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
return {
|
|
plannedActions: setupPlan({
|
|
platform: input.host.platform,
|
|
homeDirectory: input.host.homeDirectory,
|
|
installHopper: input.discovery.installHopper,
|
|
installSkill: input.installSkill,
|
|
skillClientIds: input.skillClientIds,
|
|
skillDestinations: input.host.skillDestinations(input.skillClientIds),
|
|
clients: clientPlans,
|
|
providerEnvironment: input.providerEnvironment,
|
|
...(input.discovery.linuxPackageFamily === undefined
|
|
? {}
|
|
: { linuxPackageFamily: input.discovery.linuxPackageFamily }),
|
|
command: input.command,
|
|
}),
|
|
selectedClients,
|
|
clientsToConfigure: selectedClients.filter((client) =>
|
|
clientPlans.some(({ client: planned }) => planned.name === client.name),
|
|
),
|
|
};
|
|
};
|
|
|
|
/** Describe every selected setup mutation before user approval. */
|
|
const setupPlan = (input: {
|
|
readonly platform: NodeJS.Platform;
|
|
readonly homeDirectory: 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"
|
|
? join(
|
|
input.homeDirectory,
|
|
"Applications/Hopper Disassembler.app",
|
|
)
|
|
: "system package manager",
|
|
detail:
|
|
input.platform === "linux"
|
|
? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, 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.displayName ?? client.name,
|
|
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.downloadUrl],
|
|
commands: disclosure.commands,
|
|
integrity: `${String(disclosure.expectedBytes)} bytes · SHA-1 ${disclosure.expectedSha1}`,
|
|
};
|
|
};
|
|
|
|
const majorVersion = (version: string): number =>
|
|
Number.parseInt(version.split(".")[0] ?? "0", 10);
|
|
|
|
const clientConfigurationDetail = (
|
|
client: string,
|
|
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."
|
|
: "";
|
|
return `Add the REA MCP registration for ${client}; preserve unrelated configuration. Command: ${command.map((part) => JSON.stringify(part)).join(" ")}.${environmentDetail}${hopperDetail}`;
|
|
};
|