fix(setup): preserve onboarding after refactor
This commit is contained in:
parent
db1efef9d2
commit
da48033c19
|
|
@ -23,16 +23,14 @@ import {
|
||||||
canonicalSkillNeedsInstall,
|
canonicalSkillNeedsInstall,
|
||||||
installCanonicalSkill,
|
installCanonicalSkill,
|
||||||
} from "./SetupSkill.js";
|
} from "./SetupSkill.js";
|
||||||
import { setupPlan } from "./SetupPlan.js";
|
import { discoverSetupActions } from "./SetupPlan.js";
|
||||||
import {
|
import {
|
||||||
clientConfigurationAligned,
|
clientConfigurationAligned,
|
||||||
configureJsonClient,
|
configureJsonClient,
|
||||||
configureTomlClient,
|
configureTomlClient,
|
||||||
|
inspectClientConfiguration,
|
||||||
} from "./SetupClientConfiguration.js";
|
} from "./SetupClientConfiguration.js";
|
||||||
export {
|
export { configureJsonClient, configureTomlClient };
|
||||||
configureJsonClient,
|
|
||||||
configureTomlClient,
|
|
||||||
} from "./SetupClientConfiguration.js";
|
|
||||||
import {
|
import {
|
||||||
setupInstallFailure,
|
setupInstallFailure,
|
||||||
type SetupFailureCode,
|
type SetupFailureCode,
|
||||||
|
|
@ -43,10 +41,7 @@ export type { SetupHopperInstallResult } from "./SetupInstallFailure.js";
|
||||||
/** Exact non-secret provider variables propagated into managed registrations. */
|
/** Exact non-secret provider variables propagated into managed registrations. */
|
||||||
export type SetupProviderEnvironment = Readonly<Record<string, string>>;
|
export type SetupProviderEnvironment = Readonly<Record<string, string>>;
|
||||||
|
|
||||||
export {
|
export { canonicalSkillNeedsInstall, installCanonicalSkill };
|
||||||
canonicalSkillNeedsInstall,
|
|
||||||
installCanonicalSkill,
|
|
||||||
} from "./SetupSkill.js";
|
|
||||||
|
|
||||||
const registrationCommand = (): readonly string[] =>
|
const registrationCommand = (): readonly string[] =>
|
||||||
process.env.npm_command === "exec"
|
process.env.npm_command === "exec"
|
||||||
|
|
@ -75,11 +70,7 @@ export type ClientConfigurationInspection =
|
||||||
readonly status: "invalid";
|
readonly status: "invalid";
|
||||||
readonly remediation: string;
|
readonly remediation: string;
|
||||||
};
|
};
|
||||||
/**
|
/** Effects required by the idempotent, non-blocking setup workflow. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export interface SetupHost {
|
export interface SetupHost {
|
||||||
readonly platform: NodeJS.Platform;
|
readonly platform: NodeJS.Platform;
|
||||||
readonly nodeVersion: string;
|
readonly nodeVersion: string;
|
||||||
|
|
@ -108,11 +99,7 @@ export interface SetupHost {
|
||||||
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
installSkill(): Promise<"installed" | "unchanged" | "failed">;
|
||||||
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
doctor(): Promise<Awaited<ReturnType<typeof runDoctor>>>;
|
||||||
}
|
}
|
||||||
/**
|
/** Structured setup outcome carrying remediation instead of prompting. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export interface SetupResult {
|
export interface SetupResult {
|
||||||
readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human";
|
readonly status: "planned" | "needs_confirmation" | "ready" | "needs_human";
|
||||||
readonly plannedActions: readonly SetupAction[];
|
readonly plannedActions: readonly SetupAction[];
|
||||||
|
|
@ -168,11 +155,7 @@ export type SetupConfirmation = (
|
||||||
actions: readonly SetupAction[],
|
actions: readonly SetupAction[],
|
||||||
) => Promise<boolean | SetupConfirmationDecision>;
|
) => Promise<boolean | SetupConfirmationDecision>;
|
||||||
|
|
||||||
/**
|
/** Discover, approve, and apply setup actions idempotently. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export const runSetup = async (
|
export const runSetup = async (
|
||||||
options: SetupOptions,
|
options: SetupOptions,
|
||||||
host: SetupHost = systemSetupHost(),
|
host: SetupHost = systemSetupHost(),
|
||||||
|
|
@ -190,15 +173,86 @@ export const runSetup = async (
|
||||||
const discovery = await discoverSetupActions({
|
const discovery = await discoverSetupActions({
|
||||||
host,
|
host,
|
||||||
providerEnvironment,
|
providerEnvironment,
|
||||||
|
command: registrationCommand(),
|
||||||
forceHopperInstall: options.installHopper,
|
forceHopperInstall: options.installHopper,
|
||||||
proposeHopper: options.proposeHopper ?? true,
|
proposeHopper: options.proposeHopper ?? true,
|
||||||
selectedClientIds: options.clientIds,
|
selectedClientIds: options.clientIds,
|
||||||
installSkillSelection: options.installSkill,
|
installSkillSelection: options.installSkill,
|
||||||
});
|
});
|
||||||
if (discovery.blocker !== undefined) return fail(discovery.blocker);
|
if (discovery.blocker !== undefined) return fail(discovery.blocker);
|
||||||
let { installHopper, installSkill } = discovery;
|
const selection = await resolveSetupSelection(options, discovery, confirm);
|
||||||
let detectedClients: readonly SetupClient[] = discovery.detectedClients;
|
plannedActions = selection.plannedActions;
|
||||||
plannedActions = discovery.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 approved = options.approved || plannedActions.length === 0;
|
||||||
let interactiveApproval = false;
|
let interactiveApproval = false;
|
||||||
if (!approved && confirm !== undefined && !options.structured) {
|
if (!approved && confirm !== undefined && !options.structured) {
|
||||||
|
|
@ -222,120 +276,104 @@ export const runSetup = async (
|
||||||
approved = decision.approved || plannedActions.length === 0;
|
approved = decision.approved || plannedActions.length === 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!approved)
|
return {
|
||||||
return {
|
approved,
|
||||||
status: confirm === undefined ? "needs_confirmation" : "planned",
|
interactiveApproval,
|
||||||
plannedActions,
|
installHopper,
|
||||||
appliedActions,
|
installSkill,
|
||||||
clients,
|
detectedClients,
|
||||||
doctor: await host.doctor(),
|
plannedActions,
|
||||||
remediation:
|
};
|
||||||
"Review the setup plan, then rerun interactively or with --yes.",
|
};
|
||||||
};
|
|
||||||
|
|
||||||
if (installHopper && (interactiveApproval || options.installHopper)) {
|
const installHopperAction = async (input: {
|
||||||
emitProgress(
|
readonly host: SetupHost;
|
||||||
options,
|
readonly options: SetupOptions;
|
||||||
"install_hopper",
|
readonly plannedActions: readonly SetupAction[];
|
||||||
"Hopper deep-analysis provider",
|
readonly appliedActions: string[];
|
||||||
"started",
|
readonly clients: Readonly<Record<string, ClientConfigurationResult>>;
|
||||||
);
|
readonly detectedClients: readonly SetupClient[];
|
||||||
const installed = await host.installHopper(options.installHopper);
|
readonly providerEnvironment: SetupProviderEnvironment;
|
||||||
if (installed.status === "failed") {
|
}) => {
|
||||||
emitProgress(
|
const label = "Hopper deep-analysis provider";
|
||||||
options,
|
emitProgress(input.options, {
|
||||||
"install_hopper",
|
actionId: "install_hopper",
|
||||||
"Hopper deep-analysis provider",
|
label,
|
||||||
"failed",
|
state: "started",
|
||||||
installed.remediation,
|
});
|
||||||
);
|
const installed = await input.host.installHopper(input.options.installHopper);
|
||||||
return {
|
if (installed.status === "failed") {
|
||||||
status: "needs_human",
|
emitProgress(input.options, {
|
||||||
plannedActions,
|
actionId: "install_hopper",
|
||||||
appliedActions,
|
label,
|
||||||
clients,
|
state: "failed",
|
||||||
doctor: await host.doctor(),
|
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,
|
code: installed.code,
|
||||||
remediation: installed.remediation,
|
remediation: installed.remediation,
|
||||||
};
|
},
|
||||||
}
|
|
||||||
hopperPath = installed.launcherPath;
|
|
||||||
providerEnvironment = {
|
|
||||||
...providerEnvironment,
|
|
||||||
HOPPER_LAUNCHER_PATH: installed.launcherPath,
|
|
||||||
};
|
};
|
||||||
appliedActions.push("installed_hopper");
|
}
|
||||||
emitProgress(
|
input.appliedActions.push("installed_hopper");
|
||||||
options,
|
emitProgress(input.options, {
|
||||||
"install_hopper",
|
actionId: "install_hopper",
|
||||||
"Hopper deep-analysis provider",
|
label,
|
||||||
"completed",
|
state: "completed",
|
||||||
installed.launcherPath,
|
detail: installed.launcherPath,
|
||||||
);
|
});
|
||||||
detectedClients = await filterClientsNeedingConfigure(
|
const providerEnvironment = {
|
||||||
host,
|
...input.providerEnvironment,
|
||||||
detectedClients,
|
HOPPER_LAUNCHER_PATH: installed.launcherPath,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
hopperPath: installed.launcherPath,
|
||||||
|
providerEnvironment,
|
||||||
|
detectedClients: await filterClientsNeedingConfigure(
|
||||||
|
input.host,
|
||||||
|
input.detectedClients,
|
||||||
providerEnvironment,
|
providerEnvironment,
|
||||||
registrationCommand(),
|
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 (
|
const setupFailure = async (
|
||||||
remediation: string,
|
remediation: string,
|
||||||
[host, plannedActions, appliedActions, clients]: readonly [
|
[host, plannedActions, appliedActions, clients]: readonly [
|
||||||
|
|
@ -353,92 +391,8 @@ const setupFailure = async (
|
||||||
remediation,
|
remediation,
|
||||||
});
|
});
|
||||||
|
|
||||||
const discoverSetupActions = async (
|
const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
|
||||||
host: SetupHost,
|
options.onProgress?.(event);
|
||||||
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 filterClientsNeedingConfigure = async (
|
const filterClientsNeedingConfigure = async (
|
||||||
host: SetupHost,
|
host: SetupHost,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||||
import type {
|
import type {
|
||||||
|
ClientConfigurationInspection,
|
||||||
ClientConfigurationResult,
|
ClientConfigurationResult,
|
||||||
SetupProviderEnvironment,
|
SetupProviderEnvironment,
|
||||||
} from "./Setup.js";
|
} 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 objectSchema = z.record(z.string(), z.unknown());
|
||||||
const parseOptionalObject = (value: unknown): Record<string, unknown> =>
|
const parseOptionalObject = (value: unknown): Record<string, unknown> =>
|
||||||
value === undefined ? {} : objectSchema.parse(value);
|
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 { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
|
import {
|
||||||
|
linuxHopperInstallDisclosure,
|
||||||
|
type LinuxPackageFamily,
|
||||||
|
} from "./LinuxHopper.js";
|
||||||
|
import { macHopperInstallDisclosure } from "./MacHopper.js";
|
||||||
import type {
|
import type {
|
||||||
SetupAction,
|
SetupAction,
|
||||||
SetupClient,
|
SetupClient,
|
||||||
|
SetupHost,
|
||||||
SetupProviderEnvironment,
|
SetupProviderEnvironment,
|
||||||
} from "./Setup.js";
|
} 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. */
|
/** Inputs used to build the setup mutation plan. */
|
||||||
export interface SetupPlanOptions {
|
export interface SetupPlanOptions {
|
||||||
|
readonly platform: NodeJS.Platform;
|
||||||
readonly installHopper: boolean;
|
readonly installHopper: boolean;
|
||||||
readonly installSkill: boolean;
|
readonly installSkill: boolean;
|
||||||
readonly clients: readonly SetupClient[];
|
readonly clients: readonly SetupClientPlan[];
|
||||||
readonly providerEnvironment: SetupProviderEnvironment;
|
readonly providerEnvironment: SetupProviderEnvironment;
|
||||||
|
readonly linuxPackageFamily?: LinuxPackageFamily;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the complete setup mutation plan before approval. */
|
/** Build the complete setup mutation plan before approval. */
|
||||||
export const setupPlan = (
|
export const setupPlan = (input: SetupPlanOptions): readonly SetupAction[] => [
|
||||||
platform: NodeJS.Platform,
|
...(input.installHopper
|
||||||
options: SetupPlanOptions,
|
|
||||||
): readonly SetupAction[] => [
|
|
||||||
...(options.installHopper
|
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "install_hopper",
|
id: "install_hopper",
|
||||||
|
|
@ -45,8 +60,8 @@ export const setupPlan = (
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...options.clients
|
...input.clients
|
||||||
.filter(({ format }) => format !== "unsupported")
|
.filter(({ client }) => client.format !== "unsupported")
|
||||||
.map(
|
.map(
|
||||||
({ client, operation, backupPath }): SetupAction => ({
|
({ client, operation, backupPath }): SetupAction => ({
|
||||||
id: `configure_client:${client.name}`,
|
id: `configure_client:${client.name}`,
|
||||||
|
|
@ -54,15 +69,15 @@ export const setupPlan = (
|
||||||
label: client.displayName ?? client.name,
|
label: client.displayName ?? client.name,
|
||||||
target: client.configPath,
|
target: client.configPath,
|
||||||
detail: clientConfigurationDetail(
|
detail: clientConfigurationDetail(
|
||||||
client.name,
|
client.displayName ?? client.name,
|
||||||
options.providerEnvironment,
|
input.providerEnvironment,
|
||||||
),
|
),
|
||||||
external: false,
|
external: false,
|
||||||
operation,
|
operation,
|
||||||
...(backupPath === undefined ? {} : { backupPath }),
|
...(backupPath === undefined ? {} : { backupPath }),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
...(options.installSkill
|
...(input.installSkill
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: "install_skill",
|
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 linuxDisclosure = (family: LinuxPackageFamily) => {
|
||||||
const disclosure = linuxHopperInstallDisclosure(
|
const disclosure = linuxHopperInstallDisclosure(
|
||||||
family,
|
family,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,15 @@
|
||||||
import { z } from "incur";
|
import { z } from "incur";
|
||||||
import { createInterface } from "node:readline/promises";
|
|
||||||
|
|
||||||
import { runDoctor } from "../application/Doctor.js";
|
import { runDoctor } from "../application/Doctor.js";
|
||||||
import {
|
import { runSetup, systemSetupHost } from "../application/Setup.js";
|
||||||
runSetup,
|
|
||||||
systemSetupHost,
|
|
||||||
type SetupAction,
|
|
||||||
} from "../application/Setup.js";
|
|
||||||
import { runUninstall } from "../application/Uninstall.js";
|
import { runUninstall } from "../application/Uninstall.js";
|
||||||
import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js";
|
import { runUpgrade, systemUpgradeHost } from "../application/Upgrade.js";
|
||||||
|
import {
|
||||||
|
conciseSetupResult,
|
||||||
|
confirmInteractiveSetup,
|
||||||
|
renderInteractiveSetupResult,
|
||||||
|
renderSetupProgress,
|
||||||
|
} from "../cliSetup.js";
|
||||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||||
import { logCliCommand } from "../cliLogging.js";
|
import { logCliCommand } from "../cliLogging.js";
|
||||||
import { createSystemDoctorHost } from "../doctorRuntime.js";
|
import { createSystemDoctorHost } from "../doctorRuntime.js";
|
||||||
|
|
@ -22,6 +23,7 @@ export const registerSetupCommands = (
|
||||||
): void => {
|
): void => {
|
||||||
cli.command(CLI_COMMANDS.setup, {
|
cli.command(CLI_COMMANDS.setup, {
|
||||||
description: "Install requirements and configure agents",
|
description: "Install requirements and configure agents",
|
||||||
|
outputPolicy: "agent-only",
|
||||||
options: z.object({
|
options: z.object({
|
||||||
yes: z
|
yes: z
|
||||||
.boolean()
|
.boolean()
|
||||||
|
|
@ -31,21 +33,40 @@ export const registerSetupCommands = (
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
.describe("Also approve Hopper installation with --yes"),
|
.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" },
|
alias: { yes: "y" },
|
||||||
run: ({ options, formatExplicit }) =>
|
run: ({ options, formatExplicit, agent }) =>
|
||||||
logCliCommand(logger, "setup", () =>
|
logCliCommand(logger, "setup", () =>
|
||||||
runSetup(
|
runSetupCommand({ options, formatExplicit, agent }),
|
||||||
{
|
|
||||||
approved: options.yes,
|
|
||||||
installHopper: options.installHopper,
|
|
||||||
structured: formatExplicit,
|
|
||||||
},
|
|
||||||
systemSetupHost(createSystemDoctorHost()),
|
|
||||||
options.yes || formatExplicit || process.stdin.isTTY !== true
|
|
||||||
? undefined
|
|
||||||
: confirmSetup,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
cli.command(CLI_COMMANDS.doctor, {
|
cli.command(CLI_COMMANDS.doctor, {
|
||||||
|
|
@ -83,24 +104,56 @@ export const registerSetupCommands = (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmSetup = async (
|
interface SetupCommandOptions {
|
||||||
actions: readonly SetupAction[],
|
readonly yes: boolean;
|
||||||
): Promise<boolean> => {
|
readonly installHopper: boolean;
|
||||||
process.stdout.write("\nREA setup plan\n");
|
readonly client: readonly string[];
|
||||||
for (const action of actions)
|
readonly allDetected: boolean;
|
||||||
process.stdout.write(
|
readonly skill?: boolean | undefined;
|
||||||
` - ${action.detail}\n ${action.target}${action.external ? " (external software)" : ""}\n`,
|
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({
|
const result = await runSetup(
|
||||||
input: process.stdin,
|
{
|
||||||
output: process.stdout,
|
approved: options.yes && !options.dryRun,
|
||||||
});
|
installHopper: options.installHopper,
|
||||||
try {
|
structured: input.formatExplicit || options.dryRun,
|
||||||
const answer = (await prompt.question("Continue? [Y/n] "))
|
proposeHopper: !hasExplicitScope || options.installHopper,
|
||||||
.trim()
|
...(!hasExplicitScope || options.allDetected
|
||||||
.toLowerCase();
|
? {}
|
||||||
return answer === "" || answer === "y" || answer === "yes";
|
: { clientIds: options.client }),
|
||||||
} finally {
|
...(!hasExplicitScope ? {} : { installSkill: options.skill ?? false }),
|
||||||
prompt.close();
|
...(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