mirror of
https://github.com/morluto/rea.git
synced 2026-11-09 08:17:02 +00:00
* fix(host): separate REA host support from Hopper installer targets LinuxDistribution.supported carried two meanings at once: that the host runs REA, and that the automated Hopper installer is tested on it. The doctor read the first meaning from the second, so Debian-derived hosts such as Kali reported unsupported_host while their providers worked normally. Rename the field to hopperSetupSupported, which only the Hopper installer and setup planning consume, and derive host support from the package family through supportsReaHost(). Any distribution with a recognized apt, dnf, or pacman family is now a REA host. The tested Hopper installer list is unchanged, so Debian and Kali keep falling back to configuring an existing provider instead of downloading an untested package. Verified on Kali 2026.3 (Debian testing, x86_64): rea doctor reports a healthy environment with Ghidra 12.1.4, and analyze_function decompiles an x86_64 ELF through the MCP server. npm run check, npm run docs:check, and the application and Ghidra suites pass. No Hopper or IDA host workflow was exercised. * fix(setup): stop skill checks after first drift --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { runSetup } from "./Setup.js";
|
|
import type { SetupProgressEvent } from "./SetupTypes.js";
|
|
import { FakeSetupHost, options } from "./Setup.fixture.js";
|
|
|
|
describe("skill plan destinations", () => {
|
|
it("plans a Claude-only skill in its personal location", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [{ name: "claude_code", configPath: "/claude.json" }];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), clientIds: ["claude_code"] },
|
|
host,
|
|
);
|
|
|
|
expect(
|
|
result.plannedActions
|
|
.filter(({ kind }) => kind === "install_skill")
|
|
.map(({ target }) => target),
|
|
).toEqual(["/fixture/home/.claude/skills/reverse-engineer-anything"]);
|
|
});
|
|
|
|
it("plans both shared and personal skill roots for mixed clients", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [
|
|
{ name: "claude_code", configPath: "/claude.json" },
|
|
{ name: "codex", configPath: "/codex.toml", format: "toml" },
|
|
];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), clientIds: ["claude_code", "codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(
|
|
result.plannedActions
|
|
.filter(({ kind }) => kind === "install_skill")
|
|
.map(({ target }) => target),
|
|
).toEqual([
|
|
"/fixture/home/.agents/skills/reverse-engineer-anything",
|
|
"/fixture/home/.claude/skills/reverse-engineer-anything",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("setup workflow", () => {
|
|
it("returns a complete plan without mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
const result = await runSetup(
|
|
{ ...options(false), clientIds: ["cursor"] },
|
|
host,
|
|
);
|
|
expect(result.status).toBe("needs_confirmation");
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"configure_client",
|
|
"install_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
expect(result.remediation).toBe(
|
|
"Review the setup plan, then rerun interactively or with --yes.",
|
|
);
|
|
});
|
|
|
|
it("reports ready after an accepted Linux plan installs healthy Hopper", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.appliedActions).toEqual([
|
|
"installed_hopper",
|
|
"installed_skill",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(result.remediation).toBeUndefined();
|
|
});
|
|
|
|
it("requires first-run activation after installing Hopper on macOS", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(true),
|
|
);
|
|
expect(result.status).toBe("needs_human");
|
|
expect(result.remediation).toContain("choose its demo mode");
|
|
});
|
|
|
|
it("declines an interactive plan without mutation", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve(false),
|
|
);
|
|
expect(result.status).toBe("cancelled");
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.doctorCalls).toBe(1);
|
|
});
|
|
|
|
it("treats an empty interactive selection as cancellation", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), structured: false },
|
|
host,
|
|
() => Promise.resolve({ approved: false, selectedActionIds: [] }),
|
|
);
|
|
|
|
expect(result.status).toBe("ready");
|
|
expect(result.plannedActions).toEqual([]);
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.skillInstalls).toBe(0);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.doctorCalls).toBe(2);
|
|
});
|
|
|
|
it("requires the Hopper flag for unattended setup", async () => {
|
|
const host = new FakeSetupHost();
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(result.appliedActions).toEqual([]);
|
|
});
|
|
|
|
it("returns an approved planning outcome for explicit dry runs", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
|
|
const result = await runSetup(
|
|
{ ...options(false), dryRun: true, clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.status).toBe("planned");
|
|
expect(result.plannedActions.map(({ id }) => id)).toEqual([
|
|
"configure_client:codex",
|
|
]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
|
|
it("installs Hopper when unattended authorization is explicit", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
const result = await runSetup(options(true, true), host);
|
|
expect(result.appliedActions).toEqual(["installed_hopper"]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.remediation).toBeUndefined();
|
|
});
|
|
|
|
it("reuses existing Hopper without selecting every detected client", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{ name: "claude", configPath: "/claude.json" },
|
|
{ name: "cursor", configPath: "/cursor.json" },
|
|
];
|
|
const result = await runSetup(options(true), host);
|
|
expect(result.status).toBe("ready");
|
|
expect(result.appliedActions).toEqual([]);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("setup workflow lifecycle and provider planning", () => {
|
|
it("reports append-only lifecycle progress for each selected action", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.clients = [
|
|
{
|
|
name: "codex",
|
|
displayName: "Codex",
|
|
configPath: "/codex.toml",
|
|
},
|
|
];
|
|
host.productRegistrations = [
|
|
{
|
|
client: "codex",
|
|
config_path: "/codex.toml",
|
|
command: ["rea", "mcp"],
|
|
state: "aligned",
|
|
remediation: null,
|
|
},
|
|
];
|
|
const progress: SetupProgressEvent[] = [];
|
|
await runSetup(
|
|
{ ...options(true), onProgress: (event) => progress.push(event) },
|
|
host,
|
|
);
|
|
expect(progress).toEqual([
|
|
{ actionId: "configure_client:codex", label: "Codex", state: "started" },
|
|
{
|
|
actionId: "configure_client:codex",
|
|
label: "Codex",
|
|
state: "completed",
|
|
},
|
|
{
|
|
actionId: "install_skill",
|
|
label: "REA reverse-engineering skill",
|
|
state: "started",
|
|
},
|
|
{
|
|
actionId: "install_skill",
|
|
label: "REA reverse-engineering skill",
|
|
state: "completed",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("plans and propagates BYO Ghidra without installing software", async () => {
|
|
const host = new FakeSetupHost("linux");
|
|
host.distribution = {
|
|
id: "ubuntu",
|
|
versionId: "24.04",
|
|
packageFamily: "deb",
|
|
hopperSetupSupported: true,
|
|
};
|
|
host.ghidra = "/opt/ghidra_12.1.4_PUBLIC";
|
|
host.javaHome = "/usr/lib/jvm/jdk-21";
|
|
host.doctorHealthy = true;
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "cursor", configPath: "/cursor.json" }];
|
|
|
|
const plan = await runSetup(
|
|
{ ...options(false), clientIds: ["cursor"] },
|
|
host,
|
|
);
|
|
expect(plan.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"configure_client",
|
|
]);
|
|
expect(plan.plannedActions[0]?.detail).toContain(
|
|
"GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.4_PUBLIC",
|
|
);
|
|
expect(plan.plannedActions[0]?.detail).toContain(
|
|
"JAVA_HOME=/usr/lib/jvm/jdk-21",
|
|
);
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configurations).toBe(0);
|
|
|
|
const applied = await runSetup(
|
|
{ ...options(true), clientIds: ["cursor"] },
|
|
host,
|
|
);
|
|
expect(applied.status).toBe("ready");
|
|
expect(host.hopperInstalls).toBe(0);
|
|
expect(host.configuredProviderEnvironments).toContainEqual({
|
|
GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.4_PUBLIC",
|
|
JAVA_HOME: "/usr/lib/jvm/jdk-21",
|
|
});
|
|
});
|
|
|
|
it("reinstalls existing Hopper when explicitly requested", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.hopper = "/Applications/Hopper";
|
|
host.skill = "unchanged";
|
|
host.clients = [{ name: "codex", configPath: "/codex.toml" }];
|
|
host.clientResults.set("codex", { status: "unchanged" });
|
|
|
|
const result = await runSetup(
|
|
{ ...options(true, true), clientIds: ["codex"] },
|
|
host,
|
|
);
|
|
|
|
expect(result.plannedActions.map(({ kind }) => kind)).toEqual([
|
|
"install_hopper",
|
|
"configure_client",
|
|
]);
|
|
expect(result.appliedActions).toEqual(["installed_hopper"]);
|
|
expect(host.hopperInstalls).toBe(1);
|
|
expect(host.hopperReplaceRequests).toEqual([true]);
|
|
expect(host.configurations).toBe(0);
|
|
expect(host.checkedHopperPaths).toEqual(["/manual/Hopper"]);
|
|
});
|
|
|
|
it("does not write a file when Grok Bot is selected", async () => {
|
|
const host = new FakeSetupHost();
|
|
host.doctorHealthy = true;
|
|
host.skill = "unchanged";
|
|
host.clients = [
|
|
{
|
|
name: "grok_bot",
|
|
displayName: "Grok Bot",
|
|
configPath: "/home/user/.grokbot",
|
|
format: "unsupported",
|
|
},
|
|
];
|
|
|
|
const planned = await runSetup(
|
|
{ ...options(true), clientIds: ["grok_bot"], dryRun: true },
|
|
host,
|
|
);
|
|
expect(planned.status).toBe("planned");
|
|
expect(planned.plannedActions).toEqual([]);
|
|
expect(planned.remediation).toContain("hosted computer");
|
|
expect(host.configurations).toBe(0);
|
|
|
|
const applied = await runSetup(
|
|
{ ...options(true), clientIds: ["grok_bot"] },
|
|
host,
|
|
);
|
|
expect(applied.status).toBe("needs_human");
|
|
expect(applied.appliedActions).toEqual([]);
|
|
expect(applied.remediation).toContain("hosted computer");
|
|
expect(host.configurations).toBe(0);
|
|
});
|
|
});
|