fix(setup): preserve onboarding after refactor
This commit is contained in:
parent
db1efef9d2
commit
da48033c19
|
|
@ -23,16 +23,14 @@ import {
|
|||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
} from "./SetupSkill.js";
|
||||
import { setupPlan } from "./SetupPlan.js";
|
||||
import { discoverSetupActions } from "./SetupPlan.js";
|
||||
import {
|
||||
clientConfigurationAligned,
|
||||
configureJsonClient,
|
||||
configureTomlClient,
|
||||
inspectClientConfiguration,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
export {
|
||||
configureJsonClient,
|
||||
configureTomlClient,
|
||||
} from "./SetupClientConfiguration.js";
|
||||
export { configureJsonClient, configureTomlClient };
|
||||
import {
|
||||
setupInstallFailure,
|
||||
type SetupFailureCode,
|
||||
|
|
@ -43,10 +41,7 @@ export type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
|||
/** Exact non-secret provider variables propagated into managed registrations. */
|
||||
export type SetupProviderEnvironment = Readonly<Record<string, string>>;
|
||||
|
||||
export {
|
||||
canonicalSkillNeedsInstall,
|
||||
installCanonicalSkill,
|
||||
} from "./SetupSkill.js";
|
||||
export { canonicalSkillNeedsInstall, installCanonicalSkill };
|
||||
|
||||
const registrationCommand = (): readonly string[] =>
|
||||
process.env.npm_command === "exec"
|
||||
|
|
@ -75,11 +70,7 @@ export type ClientConfigurationInspection =
|
|||
readonly status: "invalid";
|
||||
readonly remediation: string;
|
||||
};
|
||||
/**
|
||||
* Effects required by the idempotent setup workflow.
|
||||
* Implementations report interrupted installers as values: setup must not wait
|
||||
* indefinitely for stdin or a GUI response in an unattended agent invocation.
|
||||
*/
|
||||
/** Effects required by the idempotent, non-blocking setup workflow. */
|
||||
export interface SetupHost {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly nodeVersion: string;
|
||||
|
|
@ -108,11 +99,7 @@ export interface SetupHost {
|
|||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||
}
|
||||
/**
|
||||
* Structured setup outcome intended to travel back through an agent to a human.
|
||||
* `needs_human` carries remediation text instead of initiating an interactive
|
||||
* prompt, avoiding deadlocks in stdio and other unattended environments.
|
||||
*/
|
||||
/** Structured setup outcome carrying remediation instead of prompting. */
|
||||
export interface SetupResult {
|
||||
readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human";
|
||||
readonly plannedActions: readonly SetupAction[];
|
||||
|
|
@ -168,11 +155,7 @@ export type SetupConfirmation = (
|
|||
actions: readonly SetupAction[],
|
||||
) => Promise<boolean | SetupConfirmationDecision>;
|
||||
|
||||
/**
|
||||
* Install prerequisites and configure detected clients idempotently.
|
||||
* Discovery always precedes mutation. Interactive confirmation or explicit
|
||||
* unattended options authorize only the actions represented in the result.
|
||||
*/
|
||||
/** Discover, approve, and apply setup actions idempotently. */
|
||||
export const runSetup = async (
|
||||
options: SetupOptions,
|
||||
host: SetupHost = systemSetupHost(),
|
||||
|
|
@ -190,15 +173,86 @@ export const runSetup = async (
|
|||
const discovery = await discoverSetupActions({
|
||||
host,
|
||||
providerEnvironment,
|
||||
command: registrationCommand(),
|
||||
forceHopperInstall: options.installHopper,
|
||||
proposeHopper: options.proposeHopper ?? true,
|
||||
selectedClientIds: options.clientIds,
|
||||
installSkillSelection: options.installSkill,
|
||||
});
|
||||
if (discovery.blocker !== undefined) return fail(discovery.blocker);
|
||||
let { installHopper, installSkill } = discovery;
|
||||
let detectedClients: readonly SetupClient[] = discovery.detectedClients;
|
||||
plannedActions = discovery.plannedActions;
|
||||
const selection = await resolveSetupSelection(options, discovery, confirm);
|
||||
plannedActions = selection.plannedActions;
|
||||
if (!selection.approved)
|
||||
return {
|
||||
status: confirm === undefined ? "needs_confirmation" : "planned",
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
doctor: await host.doctor(),
|
||||
remediation:
|
||||
"Review the setup plan, then rerun interactively or with --yes.",
|
||||
};
|
||||
|
||||
let detectedClients: readonly SetupClient[] = selection.detectedClients;
|
||||
if (
|
||||
selection.installHopper &&
|
||||
(selection.interactiveApproval || options.installHopper)
|
||||
) {
|
||||
const install = await installHopperAction({
|
||||
host,
|
||||
options,
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
detectedClients,
|
||||
providerEnvironment,
|
||||
});
|
||||
if ("failure" in install) return install.failure;
|
||||
({ hopperPath, providerEnvironment, detectedClients } = install);
|
||||
}
|
||||
const clientFailure = await configureDetectedClients({
|
||||
host,
|
||||
detectedClients,
|
||||
providerEnvironment,
|
||||
command: registrationCommand(),
|
||||
clients,
|
||||
appliedActions,
|
||||
...(options.onProgress === undefined
|
||||
? {}
|
||||
: { onProgress: options.onProgress }),
|
||||
});
|
||||
if (clientFailure !== undefined) return fail(clientFailure);
|
||||
if (
|
||||
selection.installSkill &&
|
||||
!(await installSkillAction(host, options, appliedActions))
|
||||
)
|
||||
return fail(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
|
||||
);
|
||||
const doctor = await host.doctor();
|
||||
const remediation = finalSetupRemediation(
|
||||
host.platform,
|
||||
appliedActions.includes("installed_hopper"),
|
||||
doctor.healthy,
|
||||
hopperPath,
|
||||
);
|
||||
return {
|
||||
status: remediation === undefined ? "ready" : "needs_human",
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
doctor,
|
||||
...(remediation === undefined ? {} : { remediation }),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveSetupSelection = async (
|
||||
options: SetupOptions,
|
||||
discovery: Awaited<ReturnType<typeof discoverSetupActions>>,
|
||||
confirm: SetupConfirmation | undefined,
|
||||
) => {
|
||||
let { installHopper, installSkill, detectedClients, plannedActions } =
|
||||
discovery;
|
||||
let approved = options.approved || plannedActions.length === 0;
|
||||
let interactiveApproval = false;
|
||||
if (!approved && confirm !== undefined && !options.structured) {
|
||||
|
|
@ -222,120 +276,104 @@ export const runSetup = async (
|
|||
approved = decision.approved || plannedActions.length === 0;
|
||||
}
|
||||
}
|
||||
if (!approved)
|
||||
return {
|
||||
status: confirm === undefined ? "needs_confirmation" : "planned",
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
doctor: await host.doctor(),
|
||||
remediation:
|
||||
"Review the setup plan, then rerun interactively or with --yes.",
|
||||
};
|
||||
return {
|
||||
approved,
|
||||
interactiveApproval,
|
||||
installHopper,
|
||||
installSkill,
|
||||
detectedClients,
|
||||
plannedActions,
|
||||
};
|
||||
};
|
||||
|
||||
if (installHopper && (interactiveApproval || options.installHopper)) {
|
||||
emitProgress(
|
||||
options,
|
||||
"install_hopper",
|
||||
"Hopper deep-analysis provider",
|
||||
"started",
|
||||
);
|
||||
const installed = await host.installHopper(options.installHopper);
|
||||
if (installed.status === "failed") {
|
||||
emitProgress(
|
||||
options,
|
||||
"install_hopper",
|
||||
"Hopper deep-analysis provider",
|
||||
"failed",
|
||||
installed.remediation,
|
||||
);
|
||||
return {
|
||||
status: "needs_human",
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
doctor: await host.doctor(),
|
||||
const installHopperAction = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly options: SetupOptions;
|
||||
readonly plannedActions: readonly SetupAction[];
|
||||
readonly appliedActions: string[];
|
||||
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||
readonly detectedClients: readonly SetupClient[];
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
}) => {
|
||||
const label = "Hopper deep-analysis provider";
|
||||
emitProgress(input.options, {
|
||||
actionId: "install_hopper",
|
||||
label,
|
||||
state: "started",
|
||||
});
|
||||
const installed = await input.host.installHopper(input.options.installHopper);
|
||||
if (installed.status === "failed") {
|
||||
emitProgress(input.options, {
|
||||
actionId: "install_hopper",
|
||||
label,
|
||||
state: "failed",
|
||||
detail: installed.remediation,
|
||||
});
|
||||
return {
|
||||
failure: {
|
||||
status: "needs_human" as const,
|
||||
plannedActions: input.plannedActions,
|
||||
appliedActions: input.appliedActions,
|
||||
clients: input.clients,
|
||||
doctor: await input.host.doctor(),
|
||||
code: installed.code,
|
||||
remediation: installed.remediation,
|
||||
};
|
||||
}
|
||||
hopperPath = installed.launcherPath;
|
||||
providerEnvironment = {
|
||||
...providerEnvironment,
|
||||
HOPPER_LAUNCHER_PATH: installed.launcherPath,
|
||||
},
|
||||
};
|
||||
appliedActions.push("installed_hopper");
|
||||
emitProgress(
|
||||
options,
|
||||
"install_hopper",
|
||||
"Hopper deep-analysis provider",
|
||||
"completed",
|
||||
installed.launcherPath,
|
||||
);
|
||||
detectedClients = await filterClientsNeedingConfigure(
|
||||
host,
|
||||
detectedClients,
|
||||
}
|
||||
input.appliedActions.push("installed_hopper");
|
||||
emitProgress(input.options, {
|
||||
actionId: "install_hopper",
|
||||
label,
|
||||
state: "completed",
|
||||
detail: installed.launcherPath,
|
||||
});
|
||||
const providerEnvironment = {
|
||||
...input.providerEnvironment,
|
||||
HOPPER_LAUNCHER_PATH: installed.launcherPath,
|
||||
};
|
||||
return {
|
||||
hopperPath: installed.launcherPath,
|
||||
providerEnvironment,
|
||||
detectedClients: await filterClientsNeedingConfigure(
|
||||
input.host,
|
||||
input.detectedClients,
|
||||
providerEnvironment,
|
||||
registrationCommand(),
|
||||
);
|
||||
}
|
||||
const clientFailure = await configureDetectedClients({
|
||||
host,
|
||||
detectedClients,
|
||||
providerEnvironment,
|
||||
command: registrationCommand(),
|
||||
clients,
|
||||
appliedActions,
|
||||
...(options.onProgress === undefined
|
||||
? {}
|
||||
: { onProgress: options.onProgress }),
|
||||
});
|
||||
if (clientFailure !== undefined) return fail(clientFailure);
|
||||
if (installSkill) {
|
||||
emitProgress(
|
||||
options,
|
||||
"install_skill",
|
||||
"REA reverse-engineering skill",
|
||||
"started",
|
||||
);
|
||||
const skill = await host.installSkill();
|
||||
if (skill === "failed") {
|
||||
emitProgress(
|
||||
options,
|
||||
"install_skill",
|
||||
"REA reverse-engineering skill",
|
||||
"failed",
|
||||
);
|
||||
return fail(
|
||||
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.",
|
||||
);
|
||||
}
|
||||
if (skill === "installed") appliedActions.push("installed_skill");
|
||||
emitProgress(
|
||||
options,
|
||||
"install_skill",
|
||||
"REA reverse-engineering skill",
|
||||
skill === "installed" ? "completed" : "warning",
|
||||
skill === "unchanged" ? "Already current" : undefined,
|
||||
);
|
||||
}
|
||||
const doctor = await host.doctor();
|
||||
const remediation = finalSetupRemediation(
|
||||
host.platform,
|
||||
appliedActions.includes("installed_hopper"),
|
||||
doctor.healthy,
|
||||
hopperPath,
|
||||
);
|
||||
return {
|
||||
status: remediation === undefined ? "ready" : "needs_human",
|
||||
plannedActions,
|
||||
appliedActions,
|
||||
clients,
|
||||
doctor,
|
||||
...(remediation === undefined ? {} : { remediation }),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const installSkillAction = async (
|
||||
host: SetupHost,
|
||||
options: SetupOptions,
|
||||
appliedActions: string[],
|
||||
): Promise<boolean> => {
|
||||
const label = "REA reverse-engineering skill";
|
||||
emitProgress(options, {
|
||||
actionId: "install_skill",
|
||||
label,
|
||||
state: "started",
|
||||
});
|
||||
const skill = await host.installSkill();
|
||||
if (skill === "failed") {
|
||||
emitProgress(options, {
|
||||
actionId: "install_skill",
|
||||
label,
|
||||
state: "failed",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (skill === "installed") appliedActions.push("installed_skill");
|
||||
emitProgress(options, {
|
||||
actionId: "install_skill",
|
||||
label,
|
||||
state: skill === "installed" ? "completed" : "warning",
|
||||
...(skill === "unchanged" ? { detail: "Already current" } : {}),
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const setupFailure = async (
|
||||
remediation: string,
|
||||
[host, plannedActions, appliedActions, clients]: readonly [
|
||||
|
|
@ -353,92 +391,8 @@ const setupFailure = async (
|
|||
remediation,
|
||||
});
|
||||
|
||||
const discoverSetupActions = async (
|
||||
host: SetupHost,
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
forceHopperInstall: boolean,
|
||||
) => {
|
||||
const initialDoctor = await host.doctor();
|
||||
const linuxHopperRepairNeeded = initialDoctor.checks.some(
|
||||
({ name, ok, detail }) =>
|
||||
!ok &&
|
||||
(name === "hopper-demo-runtime" ||
|
||||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
|
||||
);
|
||||
const installHopper =
|
||||
input.forceHopperInstall ||
|
||||
(input.proposeHopper &&
|
||||
(Object.keys(providerEnvironment).length === 0 ||
|
||||
linuxHopperRepairNeeded));
|
||||
const [allDetectedClients, skillNeedsInstall] = await Promise.all([
|
||||
host.detectedClients(),
|
||||
host.skillNeedsInstall(),
|
||||
]);
|
||||
const selectedClientIdSet =
|
||||
input.selectedClientIds === undefined
|
||||
? undefined
|
||||
: new Set(input.selectedClientIds);
|
||||
const detectedClients = allDetectedClients.filter(
|
||||
({ name }) =>
|
||||
selectedClientIdSet === undefined || selectedClientIdSet.has(name),
|
||||
);
|
||||
const installSkill =
|
||||
input.installSkillSelection === false ? false : skillNeedsInstall;
|
||||
const command = registrationCommand();
|
||||
const clients = installHopper
|
||||
? detectedClients
|
||||
: await filterClientsNeedingConfigure(
|
||||
host,
|
||||
detectedClients,
|
||||
providerEnvironment,
|
||||
command,
|
||||
);
|
||||
const plannedActions = setupPlan(host.platform, {
|
||||
installHopper,
|
||||
installSkill,
|
||||
clients,
|
||||
providerEnvironment,
|
||||
});
|
||||
return {
|
||||
installHopper,
|
||||
installSkill,
|
||||
detectedClients: clientPlans.map(({ client }) => client),
|
||||
plannedActions,
|
||||
blocker,
|
||||
};
|
||||
};
|
||||
|
||||
const inspectClients = async (
|
||||
host: SetupHost,
|
||||
clients: readonly SetupClient[],
|
||||
providerEnvironment: SetupProviderEnvironment,
|
||||
command: readonly string[],
|
||||
) =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => ({
|
||||
client,
|
||||
inspection:
|
||||
(await host.inspectClientConfiguration?.(
|
||||
client,
|
||||
providerEnvironment,
|
||||
command,
|
||||
)) ?? ({ status: "update" } as const),
|
||||
})),
|
||||
);
|
||||
|
||||
const emitProgress = (
|
||||
options: SetupOptions,
|
||||
actionId: string,
|
||||
label: string,
|
||||
state: SetupProgressEvent["state"],
|
||||
detail?: string,
|
||||
): void =>
|
||||
options.onProgress?.({
|
||||
actionId,
|
||||
label,
|
||||
state,
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
});
|
||||
const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
|
||||
options.onProgress?.(event);
|
||||
|
||||
const filterClientsNeedingConfigure = async (
|
||||
host: SetupHost,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
ClientConfigurationResult,
|
||||
SetupProviderEnvironment,
|
||||
} from "./Setup.js";
|
||||
|
|
@ -187,6 +188,57 @@ export const clientConfigurationAligned = async (
|
|||
}
|
||||
};
|
||||
|
||||
/** 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(providerEnvironment, command);
|
||||
try {
|
||||
const document =
|
||||
client.format === "toml"
|
||||
? objectSchema.parse(parseToml(original))
|
||||
: parseObject(original);
|
||||
const servers = parseOptionalObject(
|
||||
client.format === "toml" ? document.mcp_servers : document.mcpServers,
|
||||
);
|
||||
if (sameConfiguration(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 objectSchema = z.record(z.string(), z.unknown());
|
||||
const parseOptionalObject = (value: unknown): Record<string, unknown> =>
|
||||
value === undefined ? {} : objectSchema.parse(value);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,39 @@
|
|||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import {
|
||||
linuxHopperInstallDisclosure,
|
||||
type LinuxPackageFamily,
|
||||
} from "./LinuxHopper.js";
|
||||
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||
import type {
|
||||
SetupAction,
|
||||
SetupClient,
|
||||
SetupHost,
|
||||
SetupProviderEnvironment,
|
||||
} from "./Setup.js";
|
||||
|
||||
/** Preflighted client mutation admitted into the setup plan. */
|
||||
export type SetupClientPlan = Readonly<{
|
||||
client: SetupClient;
|
||||
operation: "create" | "update";
|
||||
backupPath?: string;
|
||||
}>;
|
||||
|
||||
/** Inputs used to build the setup mutation plan. */
|
||||
export interface SetupPlanOptions {
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly installHopper: boolean;
|
||||
readonly installSkill: boolean;
|
||||
readonly clients: readonly SetupClient[];
|
||||
readonly clients: readonly SetupClientPlan[];
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly linuxPackageFamily?: LinuxPackageFamily;
|
||||
}
|
||||
|
||||
/** Build the complete setup mutation plan before approval. */
|
||||
export const setupPlan = (
|
||||
platform: NodeJS.Platform,
|
||||
options: SetupPlanOptions,
|
||||
): readonly SetupAction[] => [
|
||||
...(options.installHopper
|
||||
export const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
|
||||
...(input.installHopper
|
||||
? [
|
||||
{
|
||||
id: "install_hopper",
|
||||
|
|
@ -45,8 +60,8 @@ export const setupPlan = (
|
|||
},
|
||||
]
|
||||
: []),
|
||||
...options.clients
|
||||
.filter(({ format }) => format !== "unsupported")
|
||||
...input.clients
|
||||
.filter(({ client }) => client.format !== "unsupported")
|
||||
.map(
|
||||
({ client, operation, backupPath }): SetupAction => ({
|
||||
id: `configure_client:${client.name}`,
|
||||
|
|
@ -54,15 +69,15 @@ export const setupPlan = (
|
|||
label: client.displayName ?? client.name,
|
||||
target: client.configPath,
|
||||
detail: clientConfigurationDetail(
|
||||
client.name,
|
||||
options.providerEnvironment,
|
||||
client.displayName ?? client.name,
|
||||
input.providerEnvironment,
|
||||
),
|
||||
external: false,
|
||||
operation,
|
||||
...(backupPath === undefined ? {} : { backupPath }),
|
||||
}),
|
||||
),
|
||||
...(options.installSkill
|
||||
...(input.installSkill
|
||||
? [
|
||||
{
|
||||
id: "install_skill",
|
||||
|
|
@ -83,6 +98,139 @@ export const setupPlan = (
|
|||
: []),
|
||||
];
|
||||
|
||||
/** Discover and preflight the complete set of selectable setup actions. */
|
||||
export const discoverSetupActions = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly command: readonly string[];
|
||||
readonly forceHopperInstall: boolean;
|
||||
readonly proposeHopper: boolean;
|
||||
readonly selectedClientIds: readonly string[] | undefined;
|
||||
readonly installSkillSelection: boolean | undefined;
|
||||
}) => {
|
||||
const { host, providerEnvironment, command } = input;
|
||||
const initialDoctor = await host.doctor();
|
||||
const linuxHopperRepairNeeded = initialDoctor.checks.some(
|
||||
({ name, ok, detail }) =>
|
||||
!ok &&
|
||||
(name === "hopper-demo-runtime" ||
|
||||
(name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")),
|
||||
);
|
||||
const installHopper =
|
||||
input.forceHopperInstall ||
|
||||
(input.proposeHopper &&
|
||||
(Object.keys(providerEnvironment).length === 0 ||
|
||||
linuxHopperRepairNeeded));
|
||||
const [allDetectedClients, skillNeedsInstall] = await Promise.all([
|
||||
host.detectedClients(),
|
||||
host.skillNeedsInstall(),
|
||||
]);
|
||||
const selectedClientIdSet =
|
||||
input.selectedClientIds === undefined
|
||||
? undefined
|
||||
: new Set(input.selectedClientIds);
|
||||
const detectedClients = allDetectedClients.filter(
|
||||
({ name }) =>
|
||||
selectedClientIdSet === undefined || selectedClientIdSet.has(name),
|
||||
);
|
||||
const installSkill =
|
||||
input.installSkillSelection === false ? false : skillNeedsInstall;
|
||||
const clients = installHopper
|
||||
? detectedClients
|
||||
: await filterClientsNeedingConfigure({
|
||||
host,
|
||||
clients: detectedClients,
|
||||
providerEnvironment,
|
||||
command,
|
||||
});
|
||||
const inspections = await inspectClients({
|
||||
host,
|
||||
clients,
|
||||
providerEnvironment,
|
||||
command,
|
||||
});
|
||||
const invalid = inspections.find(
|
||||
({ inspection }) => inspection.status === "invalid",
|
||||
);
|
||||
const blocker =
|
||||
invalid?.inspection.status === "invalid"
|
||||
? `${invalid.client.displayName ?? invalid.client.name}: ${invalid.inspection.remediation}`
|
||||
: undefined;
|
||||
const clientPlans = inspections.flatMap(({ client, inspection }) =>
|
||||
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 }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const linuxDistribution =
|
||||
host.platform === "linux" ? await host.linuxDistribution() : undefined;
|
||||
return {
|
||||
installHopper,
|
||||
installSkill,
|
||||
detectedClients: clientPlans.map(({ client }) => client),
|
||||
plannedActions: setupPlan({
|
||||
platform: host.platform,
|
||||
installHopper,
|
||||
installSkill,
|
||||
clients: clientPlans,
|
||||
providerEnvironment,
|
||||
...(linuxDistribution?.packageFamily === undefined
|
||||
? {}
|
||||
: { linuxPackageFamily: linuxDistribution.packageFamily }),
|
||||
}),
|
||||
blocker,
|
||||
};
|
||||
};
|
||||
|
||||
const inspectClients = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly clients: readonly SetupClient[];
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly command: readonly string[];
|
||||
}) =>
|
||||
Promise.all(
|
||||
input.clients.map(async (client) => ({
|
||||
client,
|
||||
inspection:
|
||||
(await input.host.inspectClientConfiguration?.(
|
||||
client,
|
||||
input.providerEnvironment,
|
||||
input.command,
|
||||
)) ?? ({ status: "update" } as const),
|
||||
})),
|
||||
);
|
||||
|
||||
const filterClientsNeedingConfigure = async (input: {
|
||||
readonly host: SetupHost;
|
||||
readonly clients: readonly SetupClient[];
|
||||
readonly providerEnvironment: SetupProviderEnvironment;
|
||||
readonly command: readonly string[];
|
||||
}): Promise<readonly SetupClient[]> => {
|
||||
const needs = await Promise.all(
|
||||
input.clients.map((client) =>
|
||||
input.host.clientNeedsConfigure(
|
||||
client,
|
||||
input.providerEnvironment,
|
||||
input.command,
|
||||
),
|
||||
),
|
||||
);
|
||||
return input.clients.filter((_, index) => needs[index]);
|
||||
};
|
||||
|
||||
const linuxDisclosure = (family: LinuxPackageFamily) => {
|
||||
const disclosure = linuxHopperInstallDisclosure(
|
||||
family,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { z } from "incur";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
|
||||
import { runDoctor } from "../application/Doctor.js";
|
||||
import {
|
||||
runSetup,
|
||||
systemSetupHost,
|
||||
type SetupAction,
|
||||
} from "../application/Setup.js";
|
||||
import { runSetup, systemSetupHost } from "../application/Setup.js";
|
||||
import { runUninstall } from "../application/Uninstall.js";
|
||||
import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js";
|
||||
import {
|
||||
conciseSetupResult,
|
||||
confirmInteractiveSetup,
|
||||
renderInteractiveSetupResult,
|
||||
renderSetupProgress,
|
||||
} from "../cliSetup.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { createSystemDoctorHost } from "../doctorRuntime.js";
|
||||
|
|
@ -22,6 +23,7 @@ export const registerSetupCommands = (
|
|||
): void => {
|
||||
cli.command(CLI_COMMANDS.setup, {
|
||||
description: "Install requirements and configure agents",
|
||||
outputPolicy: "agent-only",
|
||||
options: z.object({
|
||||
yes: z
|
||||
.boolean()
|
||||
|
|
@ -31,21 +33,40 @@ export const registerSetupCommands = (
|
|||
.boolean()
|
||||
.default(false)
|
||||
.describe("Also approve Hopper installation with --yes"),
|
||||
client: z
|
||||
.array(
|
||||
z.enum([
|
||||
"claude_code",
|
||||
"claude_desktop",
|
||||
"codex",
|
||||
"cursor",
|
||||
"gemini_cli",
|
||||
"windsurf",
|
||||
]),
|
||||
)
|
||||
.default([])
|
||||
.describe("Agent integration to configure; repeat for multiple agents"),
|
||||
allDetected: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Configure every detected supported agent"),
|
||||
skill: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Install or skip the bundled REA skill"),
|
||||
dryRun: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Print the resolved plan without applying it"),
|
||||
accessible: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Use sequential accessible setup prompts"),
|
||||
}),
|
||||
alias: { yes: "y", installHopper: "install-hopper" },
|
||||
run: ({ options, formatExplicit }) =>
|
||||
alias: { yes: "y" },
|
||||
run: ({ options, formatExplicit, agent }) =>
|
||||
logCliCommand(logger, "setup", () =>
|
||||
runSetup(
|
||||
{
|
||||
approved: options.yes,
|
||||
installHopper: options.installHopper,
|
||||
structured: formatExplicit,
|
||||
},
|
||||
systemSetupHost(createSystemDoctorHost()),
|
||||
options.yes || formatExplicit || process.stdin.isTTY !== true
|
||||
? undefined
|
||||
: confirmSetup,
|
||||
),
|
||||
runSetupCommand({ options, formatExplicit, agent }),
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.doctor, {
|
||||
|
|
@ -83,24 +104,56 @@ export const registerSetupCommands = (
|
|||
});
|
||||
};
|
||||
|
||||
const confirmSetup = async (
|
||||
actions: readonly SetupAction[],
|
||||
): Promise<boolean> => {
|
||||
process.stdout.write("\nREA setup plan\n");
|
||||
for (const action of actions)
|
||||
process.stdout.write(
|
||||
` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`,
|
||||
interface SetupCommandOptions {
|
||||
readonly yes: boolean;
|
||||
readonly installHopper: boolean;
|
||||
readonly client: readonly string[];
|
||||
readonly allDetected: boolean;
|
||||
readonly skill?: boolean | undefined;
|
||||
readonly dryRun: boolean;
|
||||
readonly accessible: boolean;
|
||||
}
|
||||
|
||||
const runSetupCommand = async (input: {
|
||||
readonly options: SetupCommandOptions;
|
||||
readonly formatExplicit: boolean;
|
||||
readonly agent: boolean;
|
||||
}) => {
|
||||
const { options } = input;
|
||||
const interactive =
|
||||
!options.yes &&
|
||||
!options.dryRun &&
|
||||
!input.formatExplicit &&
|
||||
process.stdin.isTTY === true &&
|
||||
process.stderr.isTTY === true;
|
||||
const hasExplicitScope = setupHasExplicitScope(options);
|
||||
if (options.yes && !hasExplicitScope)
|
||||
process.stderr.write(
|
||||
"! Implicit `rea setup --yes` scope is deprecated; use `--all-detected --skill` to retain it.\n",
|
||||
);
|
||||
const prompt = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
try {
|
||||
const answer = (await prompt.question("Continue? [Y/n] "))
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return answer === "" || answer === "y" || answer === "yes";
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
const result = await runSetup(
|
||||
{
|
||||
approved: options.yes && !options.dryRun,
|
||||
installHopper: options.installHopper,
|
||||
structured: input.formatExplicit || options.dryRun,
|
||||
proposeHopper: !hasExplicitScope || options.installHopper,
|
||||
...(!hasExplicitScope || options.allDetected
|
||||
? {}
|
||||
: { clientIds: options.client }),
|
||||
...(!hasExplicitScope ? {} : { installSkill: options.skill ?? false }),
|
||||
...(interactive ? { onProgress: renderSetupProgress } : {}),
|
||||
},
|
||||
systemSetupHost(createSystemDoctorHost()),
|
||||
interactive
|
||||
? (actions) => confirmInteractiveSetup(actions, options.accessible)
|
||||
: undefined,
|
||||
);
|
||||
if (interactive) renderInteractiveSetupResult(result);
|
||||
return input.formatExplicit ? result : conciseSetupResult(result);
|
||||
};
|
||||
|
||||
const setupHasExplicitScope = (options: SetupCommandOptions): boolean =>
|
||||
options.allDetected ||
|
||||
options.client.length > 0 ||
|
||||
options.skill !== undefined ||
|
||||
options.installHopper;
|
||||
|
|
|
|||
Loading…
Reference in New Issue