Files
reverse-engineering-anything/src/application/Setup.ts
T
018f19d8c8 feat(desktop): target agentenv-framework 0.9.1464 (#9302)
* fix(desktop): preserve document URLs and value formats

* fix(desktop): bound added checks and omit noisy text guards

* fix(desktop): exclude unseeded locks from task checks

* fix(desktop): preserve draft bounds and launch URLs

* fix(desktop): render draft values using their source format

* fix(desktop): preserve launch URLs and JSON draft values

* fix(desktop): recognize captured file URL documents

* fix(desktop): render captured file URLs for runtime home

* fix(desktop): distinguish phone numbers from save metadata

* fix(desktop): preserve launch URLs and user text

* fix(desktop): preserve captured URLs and user text

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): preserve local file URL authority

* fix(desktop): render captured file URLs at launch

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): classify numeric timestamps using field context

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* fix(desktop): retain context for generated draft values

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* test(desktop): cover generated IDs in text guards

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): isolate launcher URL encoding

* fix(desktop): retain clean text in noisy record fields

* fix(desktop): classify added line values in one pass

* fix(desktop): apply XML quote rules to text drafts

* fix(desktop): distinguish XML attributes from quoted text

* fix(desktop): preserve parsed XML line context

* Format appTaskChecks.ts with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): retain partial XML syntax facts

* feat(desktop): target agentenv-framework 0.9.1464

Desktop bundles, setup's --install-agentenv and the doctor's exact-version
check now target 0.9.1464, which keeps an exported app's data/get call,
holds the computer fixture above boot dialogs, leaves an unresolved setting's
build unverified rather than failed, and mounts lock packages instead of
copying them into the image. The web environment lane stays on 0.9.1431.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(desktop): recognize markdown autolinks in drafts

* fix(desktop): reuse export exclusions in task drafts

* fix(desktop): reuse export exclusions in task drafts

* fix(desktop): report unchecked client build paths

* docs(desktop): clarify gzip seed scrubbing

* fix(desktop): report unchecked client build paths

* docs(desktop): clarify gzip scrub limits

* fix(desktop): reuse export exclusions in task drafts

* docs(desktop): clarify gzip scrub limits

* fix(setup): own desktop build process cleanup

* fix(setup): own desktop build process cleanup

* fix(doctor): compare desktop settings with resolved defaults

* fix(doctor): compare desktop settings with resolved defaults

* style(desktop): format the noisy-record draft files with the pinned oxfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): give the root-array draft test its own describe

The records describe grew past the 150-line lint limit with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(doctor): inspect client-recorded desktop runners

* fix(doctor): inspect client-recorded desktop runners

* fix(doctor): reuse matching runner probes

* fix(doctor): reuse matching runner probes

* fix(setup): propagate cancellation to desktop actions

* fix(setup): propagate cancellation to desktop actions

* fix(setup): stop Hopper on cancel, never report a cancelled setup ready, and keep a failed action's client setting

- The Hopper install gets setup's signal: Linux already honoured it, and the
  macOS installer now stops its downloads and stops before mounting or copying.
- runSetup checks the signal after the final doctor and close, so a
  cancellation there raises instead of returning ready.
- A failed pack or agentenv install no longer writes its archive or runner to
  client configs, so the setting the client already had stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(setup): keep the completed setup result in a cancellation after cleanup

A cancellation during the final doctor or host close carried only the applied
actions, dropping each client's result and backup path and the doctor report.
It now carries the whole result as setup_result, as doctor's cancellation
carries its report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(desktop): move launch execution to boundary suite

* fix(desktop): redact build schema failure output

* refactor(desktop): normalize schema failure once

* fix(desktop): omit unsafe schema parser snippets

* fix(desktop): preserve draft change and selected path evidence

* test(desktop): group retained dconf note regressions

* fix(desktop): respect archive entry format in draft checks

* test(desktop): separate unowned-file draft note cases

* fix(desktop): preserve explicitly selected journal documents

* fix(xml): avoid scanning selected ancestors per tag

* fix(desktop): exclude covered text runs from draft counts

* fix: preserve text when drafting desktop checks

* fix: restore nested desktop state aliases

* refactor: share UUID recognition across draft checks

* fix: retain multiline XML attribute context

* fix: retain relationship identifiers in task drafts

* docs: clarify desktop draft identifier handling

* test(desktop): type formatting run cases as pairs

* fix(desktop): carry document format into selector evaluation

* test(desktop): separate markup boundary regression group

* fix(desktop): propagate formats through draft selectors

* fix(desktop): switch selector format within archive entries

* test(desktop): remove duplicate seed filename case

* fix(setup): retain diagnostics when cancellation overlaps failure

* refactor(setup): keep command cancellation at its owner

* test(setup): verify cancellation at the application boundary

* test(desktop): pass JSON format in exported index parity

* docs(desktop): clarify task checks before scratch export

* fix(desktop): respell selected paths in file URLs

* test(desktop): group captured document spelling cases

* fix(desktop): bound draft preview serialization

* fix(desktop): restore selected state aliases

* fix(desktop): respect entry format in draft noise

* test(desktop): match exported alias evidence at its JSON boundary

* fix(desktop): retain app state logs in exported seeds

* fix: align desktop numeric seed comparisons

* test(desktop): keep one draft exclusion fixture import

* test(desktop): retain log eligibility across draft test groups

* fix(doctor): resolve empty registered desktop runner

* test(desktop): use the framework pin in doctor fixtures

* style: fix desktop launch test formatting

* style: format native command path schema

* fix(desktop): map selected external launch paths

* style(desktop): format seed document regressions

* test(desktop): group launch URL regressions

* fix(desktop): omit redundant nested state aliases

* fix(desktop): compare added free text as a whole

* fix(desktop): check recompressed seeds as shipped

* docs(desktop): clarify recovered seed format

* test(desktop): group shipped gzip seed checks

* fix: retain quoted identifier text in task drafts

* fix: recognize Unicode XML in desktop state readers

* fix(desktop): bound nested draft checks

* fix(desktop): explain missing arrays in truncated views

* fix(desktop): find XML type references in archives

* test(desktop): remove duplicated capture assertions

* fix(desktop): retain nested omitted text boundaries

* style(desktop): format draft value imports

* test(desktop): group model and draft value checks

* fix(desktop): map captured launch paths once

* refactor: remove unused whole-text guard state

* refactor: stop passing unused text children

* test: align free-text checks with whole-note policy

* fix: validate recompressed desktop archive seeds

* style(desktop): format archive seed regression

* fix(desktop): retain placed-item sibling guards

* fix(desktop): preserve archive paths and bound doctor reads

* fix(doctor): refuse non-files before descriptor admission

* fix(setup): track remaining cleanup resources across retries

* test(desktop): specify XML format in heap pairing fixture

* fix(doctor): query agentenv for model endpoint

* fix(doctor): preserve uncertainty for empty endpoint override

* style(desktop): format framework pin fixture

* fix(desktop): redact runtime schema errors

* fix(desktop): normalize captured file URLs

* fix(desktop): report seeded files by capture path

* fix(desktop): retain startup readiness failure stage

* fix(desktop): retain JSON date checks

* fix(desktop): preserve archive formatting checks

* fix(desktop): preserve explicitly selected journal files

* fix(desktop): report build command transport limits

* docs(desktop): clarify unreadable dconf versions

* test(desktop): remove duplicate history regression

* fix(desktop): align XML names with exported reader

* fix(desktop): create selected state alias targets

* fix(desktop): inspect supported runtime archives

* fix(desktop): retain record identity in draft checks

* fix(desktop): inspect supported runtime archives

* fix(setup): retain completed actions on cancellation

* docs(desktop): clarify document lock exclusion

* fix(desktop): scrub XML independently of reader compatibility

* fix(desktop): preserve file aliases and reader diagnostics

* style(desktop): format review fixes

* style(desktop): format capture review fixes

* fix(desktop): retain merged installer behavior

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:12:10 +08:00

779 lines
25 KiB
TypeScript

import type {
ClientConfigurationResult,
SetupAction,
SetupClientState,
SetupConfirmation,
SetupConfirmationDecision,
SetupDoctorSummary,
SetupHost,
SetupOptions,
SetupProgressEvent,
SetupProviderEnvironment,
SetupResult,
} from "./SetupTypes.js";
import {
manualRegistrationRemediation,
type SetupClient,
} from "./SupportedClients.js";
import type { DoctorReport, DoctorScope } from "./Doctor.js";
import { configureDetectedClients } from "./SetupClients.js";
import {
discoverSetupState,
hopperInstallRefusal,
planSetupActions,
} from "./SetupPlan.js";
import {
filterClientsNeedingConfigure,
hostRemediation,
systemSetupHost,
} from "./SetupHost.js";
import {
AnalysisError,
type AnalysisCleanupObservation,
} from "../domain/analysisErrorBase.js";
import { setupCleanupError, setupCleanupReason } from "./SetupCleanupOwner.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { assertSetupActive } from "./SetupCancellation.js";
import type {
SetupSkillInstallError,
SkillInstallResult,
} from "./SetupSkill.js";
import { planDesktopBuild } from "./SetupDesktopBuild.js";
import { DESKTOP_BUILD_PROVIDER_ID } from "./DoctorProviderIds.js";
import type {
SetupHopperInstallFailure,
SetupHopperInstallResult,
} from "./SetupInstallFailure.js";
/** Completed setup facts retained if a later step is cancelled. */
interface SetupProgress {
plannedActions: readonly SetupAction[];
appliedActions: string[];
clients: Record<string, ClientConfigurationResult>;
clientStates: readonly SetupClientState[];
desktopFailure?: string | undefined;
hopperOutcome?: SetupHopperInstallResult | undefined;
skill?: SkillInstallResult | undefined;
}
const setupProgressFacts = (progress: SetupProgress) => {
const skill = progress.skill;
return jsonValueSchema.parse({
plannedActions: progress.plannedActions,
appliedActions: progress.appliedActions,
clients: progress.clients,
clientStates: progress.clientStates,
...(progress.desktopFailure === undefined
? {}
: { desktopFailure: progress.desktopFailure }),
...(progress.hopperOutcome === undefined
? {}
: { hopperOutcome: progress.hopperOutcome }),
...(skill === undefined
? {}
: {
skill: skill.ok
? { status: skill.value }
: {
status: "failed",
path: skill.error.path,
stage: skill.error.stage,
reason: skill.error.reason,
rollbackFailures: skill.error.rollbackFailures,
recoveryBackupPaths: skill.error.recoveryBackupPaths,
remediation: skill.error.remediation,
},
}),
});
};
/** Discover, approve, and apply setup actions idempotently. */
export const runSetup = async (
options: SetupOptions,
host: SetupHost = systemSetupHost(),
confirm?: SetupConfirmation,
): Promise<SetupResult> => {
const progress: SetupProgress = {
plannedActions: [],
appliedActions: [],
clients: {},
clientStates: [],
};
let result: SetupResult | undefined;
const pending: { skill?: SetupSkillInstallError } = {};
let primaryFailure: unknown;
let threw = false;
try {
result = await runSetupWorkflow(options, host, progress, confirm, pending);
} catch (cause: unknown) {
if (cause instanceof AnalysisCancelledError)
cause.retainDiagnostics({ setup_progress: setupProgressFacts(progress) });
threw = true;
primaryFailure = cause;
}
let cleanup: AnalysisError | undefined;
try {
const report = await host.close?.();
cleanup = report === undefined ? undefined : setupCleanupError(report);
} catch (cause: unknown) {
cleanup = setupCleanupError(cause);
}
if (cleanup !== undefined && !cleanup.cleanupIncomplete) cleanup = undefined;
if (threw) {
if (cleanup === undefined) throw primaryFailure;
if (primaryFailure instanceof AnalysisError)
throw analysisErrorWithCleanupFailure(
primaryFailure,
cleanup,
"setup",
).bindCleanupDependencies([primaryFailure, cleanup]);
throw new AggregateError(
[primaryFailure, cleanup],
"Setup and resource cleanup failed.",
{ cause: primaryFailure },
);
}
if (result === undefined) throw new Error("Setup returned no result.");
if (pending.skill !== undefined) {
const settled =
pending.skill.cleanupOwners.length > 0 &&
pending.skill.cleanupOwners.every(({ lease }) => lease.released);
if (settled)
emitProgress(options, {
actionId: "install_skill",
label: "REA reverse-engineering skill",
state: "completed",
});
else
result = {
...result,
status: "needs_human",
code: result.code ?? "skill_install_failed",
remediation:
combineRemediation(pending.skill.remediation, result.remediation) ??
pending.skill.remediation,
};
}
// Cancellation observed only during the final doctor or cleanup is not success.
if (options.signal?.aborted === true) {
const cancelled = new AnalysisCancelledError("setup", {
cause: options.signal.reason,
diagnostics: { setup_result: jsonValueSchema.parse(result) },
});
if (cleanup !== undefined)
throw analysisErrorWithCleanupFailure(
cancelled,
cleanup,
"setup",
).bindCleanupDependencies([cancelled, cleanup]);
throw cancelled;
}
if (cleanup === undefined) return result;
const cleanupReason = setupCleanupReason(cleanup);
const cleanupReport: AnalysisCleanupObservation = {
reason: cleanupReason,
get resources() {
return cleanup.cleanupResources;
},
};
return {
...result,
status: "needs_human",
code: result.code ?? "cleanup_failed",
get cleanup() {
return cleanup.cleanupIncomplete ? cleanupReport : undefined;
},
remediation: result.remediation
? `${result.remediation} ${cleanupReason}`
: cleanupReason,
};
};
const runSetupWorkflow = async (
options: SetupOptions,
host: SetupHost,
progress: SetupProgress,
confirm: SetupConfirmation | undefined,
pending: { skill?: SetupSkillInstallError },
): Promise<SetupResult> => {
assertSetupActive(options.signal);
const appliedActions = progress.appliedActions;
let plannedActions: readonly SetupAction[] = [];
const clients = progress.clients;
let clientStates: readonly SetupClientState[] = [];
let readinessScope = options.readinessScope;
const fail = (remediation: string) =>
setupFailure(
remediation,
[host, plannedActions, appliedActions, clients, clientStates],
readinessScope,
);
const failSkill = async (
error: SetupSkillInstallError,
): Promise<SetupResult> => ({
...(await fail(error.remediation)),
code: "skill_install_failed",
});
const unsupported = hostRemediation(host);
if (unsupported !== undefined) return fail(unsupported);
const initialState = await host.initialSetupState(options.readinessScope);
let { hopperPath, providerEnvironment } = initialState;
const discovery = await discoverSetupState({
host,
providerConfigured: initialState.providerConfigured,
forceHopperInstall: options.installHopper,
hopperLauncherConfigured: initialState.hopperLauncherConfigured === true,
proposeHopper:
options.proposeHopper ?? (confirm !== undefined && !options.structured),
initialDoctor: initialState.doctor,
});
// The advertised family scope is a registration setting, not a detected
// provider fact; merge it after discovery so it cannot suppress the
// Hopper installation offer on an otherwise unconfigured machine.
if (options.toolFamilies !== undefined)
providerEnvironment = {
...providerEnvironment,
REA_MCP_TOOL_FAMILIES: options.toolFamilies,
};
clientStates = discovery.clientStates;
progress.clientStates = clientStates;
const clientSelectionAllowed =
options.clientIds === undefined &&
options.readinessScope?.clients === undefined &&
options.allDetectedClients !== true &&
options.installHopper !== true &&
options.skillClientIds === undefined &&
options.installSkill === undefined;
const interactiveSelection =
confirm !== undefined && !options.structured && clientSelectionAllowed;
const requestedClientIds =
options.clientIds ?? options.readinessScope?.clients;
let selectedClientIds = [
...new Set(
options.allDetectedClients
? [...(requestedClientIds ?? []), ...discovery.detectedClientIds]
: (requestedClientIds ?? discovery.defaultClientIds),
),
];
let selectedSkillClientIds =
options.skillClientIds ??
options.readinessScope?.skillClients ??
selectedClientIds;
let skillNeedsInstall = await host.skillNeedsInstall(selectedSkillClientIds);
let skillSelected =
options.installSkill === true ||
(options.installSkill !== false &&
(selectedClientIds.length > 0 || selectedSkillClientIds.length > 0));
let installSkill = skillSelected && skillNeedsInstall;
let planDiscovery = discovery;
if (interactiveSelection) {
// The skill choice is intent; its final destination is determined after
// selecting clients. An empty client selection names the shared CLI copy.
const selectionSkillClientIds: readonly string[] = [];
const offerSkillAction = options.installSkill !== false;
const selectionActions = await planSetupActions({
discovery,
host,
providerEnvironment,
command: host.registrationCommand,
clientIds: [],
skillClientIds: selectionSkillClientIds,
installSkill: offerSkillAction,
});
const decision = await confirm(selectionActions.plannedActions, {
stage: "select",
clientStates,
selectedClientIds: discovery.defaultClientIds,
clientSelectionAllowed,
});
assertSetupActive(options.signal);
if (isSetupDecisionCancelled(decision))
return setupCancelled(
selectionActions.plannedActions,
clients,
clientStates,
discovery.initialDoctor,
);
const selectedActionIds = new Set(
typeof decision === "boolean"
? decision
? [
...selectionActions.plannedActions.map(({ id }) => id),
...discovery.defaultClientIds.map(
(id) => `configure_client:${id}`,
),
]
: []
: decision.selectedActionIds,
);
selectedClientIds = [...selectedActionIds]
.filter((id) => id.startsWith("configure_client:"))
.map((id) => id.slice("configure_client:".length));
skillNeedsInstall = await host.skillNeedsInstall(selectedClientIds);
const proposedInstallAction = selectionActions.plannedActions.some(
({ id }) => id === "install_hopper",
);
planDiscovery = {
...discovery,
installHopper:
options.installHopper ||
(proposedInstallAction && selectedActionIds.has("install_hopper")),
};
// The offered skill action can carry a client-specific ID (for example
// `install_skill:claude_code`); without a selected client it means the
// shared CLI skill.
const skillActionSelected = selectionActions.plannedActions.some(
({ id, kind }) => kind === "install_skill" && selectedActionIds.has(id),
);
skillSelected =
options.installSkill === true ||
(skillActionSelected && selectedClientIds.length === 0) ||
(options.installSkill !== false && selectedClientIds.length > 0);
installSkill = skillSelected && skillNeedsInstall;
selectedSkillClientIds = selectedClientIds;
}
const desktop = await planDesktopBuild(
host.desktopBuild,
options,
selectedClientIds,
);
if (desktop.blocker !== undefined) return fail(desktop.blocker);
const planned = await planSetupActions({
discovery: planDiscovery,
host,
providerEnvironment,
command: host.registrationCommand,
clientIds: selectedClientIds,
skillClientIds: selectedSkillClientIds,
installSkill,
clientEnvironments: desktop.clientEnvironments,
});
plannedActions = [...desktop.actions, ...planned.plannedActions];
progress.plannedActions = plannedActions;
if (planned.blocker !== undefined) return fail(planned.blocker);
const hopperRefusal = hopperInstallRefusal(planDiscovery);
if (hopperRefusal !== undefined) return fail(hopperRefusal);
const planSelection = {
installHopper: planDiscovery.installHopper,
installSkill,
skillSelected,
selectedSkillClientIds,
selectedClients: planned.selectedClients,
clientsToConfigure: planned.clientsToConfigure,
};
readinessScope = resolvedReadinessScope(
options.readinessScope,
planSelection,
desktop.selected,
);
const manualRemediation = combineRemediation(
manualClientsRemediation(planned.selectedClients),
desktop.remediation,
);
if (skillSelected) {
const admission = await host.validateSkillDirectories(
selectedSkillClientIds,
);
if (!admission.ok) return failSkill(admission.error);
}
if (options.dryRun === true)
return {
status: "planned",
plannedActions,
appliedActions,
clients,
doctor: summarizeDoctor(discovery.initialDoctor),
clientStates,
...(manualRemediation === undefined
? {}
: { remediation: manualRemediation }),
};
let approved = options.approved || plannedActions.length === 0;
let interactiveApproval = false;
if (!approved && confirm !== undefined && !options.structured) {
const decision = await confirm?.(plannedActions, {
stage: "confirm",
clientStates,
selectedClientIds,
clientSelectionAllowed: false,
});
assertSetupActive(options.signal);
if (decision !== undefined) {
if (isSetupDecisionCancelled(decision))
return setupCancelled(
plannedActions,
clients,
clientStates,
discovery.initialDoctor,
);
approved = typeof decision === "boolean" ? decision : decision.approved;
interactiveApproval = approved;
}
}
if (!approved)
return {
status: confirm === undefined ? "needs_confirmation" : "cancelled",
plannedActions,
appliedActions,
clients,
doctor: summarizeDoctor(discovery.initialDoctor),
clientStates,
...(confirm === undefined
? {
remediation:
"Review the setup plan, then rerun interactively or with --yes.",
}
: {}),
};
// Approval may pause while a skill manager changes a selected directory.
if (planSelection.skillSelected) {
const admission = await host.validateSkillDirectories(
selectedSkillClientIds,
);
if (!admission.ok) return failSkill(admission.error);
}
assertSetupActive(options.signal);
const desktopFailure = await desktop.apply(
appliedActions,
options.onProgress,
options.signal,
);
progress.desktopFailure = desktopFailure;
assertSetupActive(options.signal);
let selectedClients: readonly SetupClient[] =
planSelection.clientsToConfigure;
let hopperWarning: string | undefined;
let hopperFailure: SetupHopperInstallFailure | undefined;
if (
planSelection.installHopper &&
(interactiveApproval || options.installHopper)
) {
const install = await installHopperAction({
host,
options,
appliedActions,
selectedClients,
providerEnvironment,
clientEnvironments: desktop.clientEnvironments,
recordResult: (result) => {
progress.hopperOutcome = result;
},
});
// Hopper is optional: a failed install still applies the client
// registrations and skill approved with it (#5113).
if ("failure" in install) hopperFailure = install.failure;
else
({
hopperPath,
providerEnvironment,
selectedClients,
warning: hopperWarning,
} = install);
}
assertSetupActive(options.signal);
const clientFailure = await configureDetectedClients({
host,
detectedClients: selectedClients,
providerEnvironment,
clientEnvironments: desktop.clientEnvironments,
command: host.registrationCommand,
clients,
appliedActions,
signal: options.signal,
...(options.onProgress === undefined
? {}
: { onProgress: options.onProgress }),
});
assertSetupActive(options.signal);
// A later failure must not hide a failed Hopper install: keep its repair
// steps, and its code when the later failure carries none.
const withHopperFailure = (result: SetupResult): SetupResult =>
hopperFailure === undefined
? result
: {
...result,
code: result.code ?? hopperFailure.code,
remediation:
combineRemediation(hopperFailure.remediation, result.remediation) ??
hopperFailure.remediation,
...hopperDownload(hopperFailure),
};
if (clientFailure !== undefined)
return withHopperFailure(await fail(clientFailure));
if (planSelection.installSkill) {
const skill = await installSkillAction(
host,
options,
appliedActions,
selectedSkillClientIds,
);
progress.skill = skill;
if (!skill.ok && skill.error.completed) pending.skill = skill.error;
assertSetupActive(options.signal);
if (!skill.ok && !skill.error.completed) {
return withHopperFailure(await failSkill(skill.error));
}
}
const doctor = summarizeDoctor(await host.doctor(readinessScope));
const remediation = combineRemediation(
desktopFailure,
hopperFailure?.remediation ??
desktop.readinessRemediation(doctor.healthy) ??
finalSetupRemediation(
host.platform,
appliedActions.includes("installed_hopper"),
doctor.healthy,
// Offer installation only where the explicit gate accepts it.
hopperPath === undefined &&
planDiscovery.hopperInstall.supported !== false,
),
hopperWarning,
manualRemediation,
);
return {
status: remediation === undefined ? "ready" : "needs_human",
plannedActions,
appliedActions,
clients,
doctor,
clientStates,
...(remediation === undefined ? {} : { remediation }),
...(hopperFailure === undefined
? {}
: { code: hopperFailure.code, ...hopperDownload(hopperFailure) }),
};
};
const hopperDownload = (
failure: SetupHopperInstallFailure,
): Pick<SetupResult, "download"> =>
failure.download === undefined ? {} : { download: failure.download };
const installHopperAction = async (input: {
readonly host: SetupHost;
readonly options: SetupOptions;
readonly appliedActions: string[];
readonly selectedClients: readonly SetupClient[];
readonly providerEnvironment: SetupProviderEnvironment;
readonly clientEnvironments: Readonly<
Record<string, SetupProviderEnvironment>
>;
readonly recordResult: (result: SetupHopperInstallResult) => void;
}) => {
const label = "Hopper deep-analysis provider";
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: "started",
});
const installed = await input.host.installHopper(
input.options.installHopper,
input.options.signal,
);
input.recordResult(installed);
if (installed.status === "failed") {
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: "failed",
detail: installed.remediation,
});
return { failure: installed };
}
input.appliedActions.push("installed_hopper");
const warning = [installed.warning, installed.cleanupFailure]
.filter((value): value is string => value !== undefined)
.join(" ");
emitProgress(input.options, {
actionId: "install_hopper",
label,
state: warning === "" ? "completed" : "warning",
detail:
warning === ""
? installed.launcherPath
: `${installed.launcherPath}; ${warning}`,
});
const providerEnvironment = {
...input.providerEnvironment,
HOPPER_LAUNCHER_PATH: installed.launcherPath,
};
return {
hopperPath: installed.launcherPath,
providerEnvironment,
...(warning === "" ? {} : { warning }),
selectedClients: await filterClientsNeedingConfigure(
input.host,
input.selectedClients,
{ providerEnvironment, clientEnvironments: input.clientEnvironments },
input.host.registrationCommand,
),
};
};
const installSkillAction = async (
host: SetupHost,
options: SetupOptions,
appliedActions: string[],
skillClientIds: readonly string[],
): Promise<SkillInstallResult> => {
const label = "REA reverse-engineering skill";
emitProgress(options, {
actionId: "install_skill",
label,
state: "started",
});
const skill = await host.installSkill(skillClientIds);
if (!skill.ok) {
if (skill.error.completed) appliedActions.push("installed_skill");
emitProgress(options, {
actionId: "install_skill",
label,
state: skill.error.completed ? "warning" : "failed",
detail: skill.error.message,
});
return skill;
}
if (skill.value === "installed") appliedActions.push("installed_skill");
emitProgress(options, {
actionId: "install_skill",
label,
state: skill.value === "installed" ? "completed" : "warning",
...(skill.value === "unchanged" ? { detail: "Already current" } : {}),
});
return skill;
};
const setupFailure = async (
remediation: string,
[host, plannedActions, appliedActions, clients, clientStates]: readonly [
SetupHost,
readonly SetupAction[],
readonly string[],
Readonly<Record<string, ClientConfigurationResult>>,
readonly SetupClientState[],
],
scope?: DoctorScope,
): Promise<SetupResult> => {
return {
status: "needs_human",
plannedActions,
appliedActions,
clients,
doctor: summarizeDoctor(await host.doctor(scope)),
clientStates,
remediation,
};
};
const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void =>
options.onProgress?.(event);
const manualClientsRemediation = (
clients: readonly SetupClient[],
): string | undefined =>
clients
.map((client) => manualRegistrationRemediation(client.name))
.find((remediation) => remediation !== undefined);
const combineRemediation = (
...parts: readonly (string | undefined)[]
): string | undefined => {
const present = parts.filter(
(part): part is string => part !== undefined && part !== "",
);
return present.length === 0 ? undefined : present.join(" ");
};
const finalSetupRemediation = (
platform: NodeJS.Platform,
installedHopper: boolean,
healthy: boolean,
offerHopperInstall: boolean,
): string | undefined => {
if (platform === "darwin" && installedHopper)
return "Open Hopper, choose its demo mode or activate a license, then rerun rea doctor --json.";
if (healthy) return undefined;
return offerHopperInstall
? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis."
: "Run rea doctor and apply each reported remediation.";
};
const isSetupDecisionCancelled = (
decision: boolean | SetupConfirmationDecision,
): boolean =>
typeof decision === "boolean" ? !decision : decision.cancelled === true;
const setupCancelled = (
plannedActions: readonly SetupAction[],
clients: Readonly<Record<string, ClientConfigurationResult>>,
clientStates: readonly SetupClientState[],
doctor: DoctorReport,
): SetupResult => ({
status: "cancelled",
plannedActions,
appliedActions: [],
clients,
doctor: summarizeDoctor(doctor),
clientStates,
});
const resolvedReadinessScope = (
requested: DoctorScope | undefined,
selection: {
readonly selectedClients: readonly SetupClient[];
readonly installHopper: boolean;
readonly skillSelected?: boolean;
readonly selectedSkillClientIds: readonly string[];
},
desktopBuild: boolean,
): DoctorScope => ({
clients: selection.selectedClients.map(({ name }) => name),
providers: [
...new Set([
...(selection.installHopper ? ["hopper"] : (requested?.providers ?? [])),
...(desktopBuild ? [DESKTOP_BUILD_PROVIDER_ID] : []),
]),
],
skill: selection.skillSelected === true || requested?.skill === true,
...(selection.skillSelected === true || requested?.skill === true
? { skillClients: selection.selectedSkillClientIds }
: {}),
});
const summarizeDoctor = (report: DoctorReport): SetupDoctorSummary => ({
healthy: report.healthy,
environment_healthy: report.environment_healthy,
scope: report.scope,
availableProviders: [
...(report.hopperPath !== undefined &&
report.checks
.filter(({ name }) => name.startsWith("hopper"))
.every(({ ok }) => ok)
? ["hopper"]
: []),
...(report.providerInspections ?? [])
.filter(
({ available, checks }) => available && checks.every(({ ok }) => ok),
)
.map(({ id }) => id),
],
...(report.hopperPath === undefined ? {} : { hopperPath: report.hopperPath }),
...(report.providerInspections === undefined
? {}
: { providerInspections: report.providerInspections }),
...(report.identity === undefined
? {}
: {
identity: {
skill: report.identity.skill,
registrations: report.identity.registrations,
},
}),
});