mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 17:59:08 +00:00
* feat(setup): support Cline as a client * test(setup): cover Cline doctor and uninstall ownership * docs(setup): document Cline support * test(setup): verify Cline against an installed client * test(setup): include Cline in the exhaustive client detection list * docs(setup): note that a hand-written rea entry blocks Cline setup * fix(setup): isolate Cline verification and await MCP discovery * fix(verification): own and isolate Cline discovery * fix(setup): preserve native Cline transport settings --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
145 lines
6.6 KiB
TypeScript
145 lines
6.6 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
it.each(["success", "wrong-server", "cleanup-failure"] as const)(
|
|
"owns Cline verifier orchestration through %s",
|
|
async (mode) => {
|
|
const state = await createTestTempDirectory("rea-cline-verifier-");
|
|
const fixture = join(state, "owners.mjs");
|
|
const hook = join(state, "hook.mjs");
|
|
await writeFile(
|
|
fixture,
|
|
`import assert from 'node:assert/strict';
|
|
import {appendFile, mkdir, stat, writeFile} from 'node:fs/promises';
|
|
import {join} from 'node:path';
|
|
const event = async (value) => appendFile(process.env.STATE+'/events',value+'\\n');
|
|
export async function createVerifierCommands(){
|
|
const owners=[];
|
|
return {
|
|
adopt(owner){owners.push(owner);},
|
|
async exec(command,args,options={}){
|
|
const env=options.env;
|
|
assert(env,'commands must receive isolated environment');
|
|
assert.equal((await stat(env.HOME)).isDirectory(),true,'HOME must exist before any command');
|
|
assert.equal(env.USERPROFILE,env.HOME);
|
|
for(const key of ['CLINE_MCP_SETTINGS_PATH','CLINE_DATA_DIR','CLINE_DIR']) assert.equal(env[key],undefined,key+' must be absent');
|
|
if(args.includes('--version')){await event('version');return {stdout:'recorded Cline 1',stderr:''};}
|
|
if(args.includes('setup')){
|
|
assert(args.includes('cline'));assert.equal(options.cwd,env.HOME);
|
|
const path=join(env.HOME,'.cline','data','settings');await mkdir(path,{recursive:true});
|
|
await writeFile(join(path,'cline_mcp_settings.json'),JSON.stringify({mcpServers:{rea:{command:'recorded-server',args:['mcp'],env:{RECORDED:'yes'}}}}));
|
|
await event('setup');return {stdout:'{}',stderr:''};
|
|
}
|
|
assert.deepEqual(args,['--data-dir',join(env.HOME,'.cline','data'),'config','mcp']);
|
|
await event('client-list');return {stdout:'rea [stdio]',stderr:''};
|
|
},
|
|
async close(){await event('commands-close-start');for(const owner of owners)await owner.close();await event('commands-close-done');}
|
|
};
|
|
}
|
|
export async function createOwnedMcpStdioTransport(options){
|
|
assert.equal(options.command,'recorded-server');assert.deepEqual(options.arguments,['mcp']);
|
|
assert.equal(options.hostEnvironment.RECORDED,'yes');
|
|
let closing;
|
|
return {
|
|
async start(){await event('transport-start');},
|
|
async send(message){
|
|
if(message.method==='notifications/initialized'){await event('initialized');return;}
|
|
if(message.method==='initialize'||message.method==='tools/list'){
|
|
await event(message.method+'-request');
|
|
setTimeout(async()=>{
|
|
await event(message.method+'-reply');
|
|
this.onmessage?.({jsonrpc:'2.0',id:message.id,result:message.method==='initialize'?{protocolVersion:'2025-11-25',capabilities:{tools:{}},serverInfo:{name:process.env.MODE==='wrong-server'?'other':'rea',version:'1'}}:{tools:[{name:'recorded_tool',inputSchema:{type:'object'}}]}});
|
|
},15);
|
|
}
|
|
},
|
|
close(){return closing??= (async()=>{
|
|
await event('transport-close-start');await new Promise(resolve=>setTimeout(resolve,15));
|
|
if(process.env.MODE==='cleanup-failure'){await event('transport-close-failed');throw Error('recorded transport cleanup failure');}
|
|
await event('transport-close-done');this.onclose?.();
|
|
})();}
|
|
};
|
|
}
|
|
export function createVerifierRun(){return {run_id:'recorded-run'};}
|
|
export async function completeVerifierRun(run){await event('lineage');return {...run,process_lineage:{status:'recorded',descendants:[]}};}
|
|
export function execFile(){throw Error('unowned child_process execution is forbidden in this orchestration fixture');}
|
|
export function spawn(){throw Error('unowned child_process execution is forbidden in this orchestration fixture');}
|
|
`,
|
|
);
|
|
await writeFile(
|
|
hook,
|
|
`import {registerHooks} from 'node:module';import{pathToFileURL}from'node:url';
|
|
registerHooks({resolve(specifier,context,next){
|
|
if(context.parentURL?.endsWith('/scripts/verify-cline-client.mjs')&&['./lib/verify-commands.mjs','./lib/owned-mcp-stdio.mjs','./lib/verifier-run.mjs','node:child_process'].includes(specifier))return{url:pathToFileURL(process.env.STATE+'/owners.mjs').href,shortCircuit:true};
|
|
return next(specifier,context);
|
|
}});
|
|
`,
|
|
);
|
|
const script = fileURLToPath(
|
|
new URL("../../../scripts/verify-cline-client.mjs", import.meta.url),
|
|
);
|
|
const result = spawnSync(process.execPath, ["--import", hook, script], {
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
env: {
|
|
...process.env,
|
|
STATE: state,
|
|
MODE: mode,
|
|
REA_VERIFY_CLINE_COMMAND: "recorded-cline",
|
|
CLINE_MCP_SETTINGS_PATH: join(state, "outside-settings.json"),
|
|
CLINE_DATA_DIR: join(state, "outside-data"),
|
|
CLINE_DIR: join(state, "outside-root"),
|
|
TMPDIR: state,
|
|
TMP: state,
|
|
TEMP: state,
|
|
},
|
|
});
|
|
const events = await readFile(join(state, "events"), "utf8").catch(
|
|
() => "",
|
|
);
|
|
expect(result.error).toBeUndefined();
|
|
expect(events, result.stderr + result.stdout).toContain(
|
|
"version\nsetup\nclient-list\n",
|
|
);
|
|
expect(events).toContain("initialize-reply\n");
|
|
expect(events).toContain("tools/list-reply\n");
|
|
expect(events).toContain("commands-close-start\n");
|
|
expect(events.indexOf("tools/list-reply\n")).toBeLessThan(
|
|
events.indexOf("transport-close-start\n"),
|
|
);
|
|
if (mode === "cleanup-failure") {
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("recorded transport cleanup failure");
|
|
expect(events).toContain("transport-close-failed\n");
|
|
expect(events).not.toContain("lineage\n");
|
|
expect(result.stdout).not.toContain('"status": "verified"');
|
|
} else {
|
|
expect(events).toContain("transport-close-done\n");
|
|
expect(events).toContain("commands-close-done\n");
|
|
if (mode === "wrong-server") {
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toMatch(/initialize|identify|server|rea/u);
|
|
expect(result.stdout).not.toContain('"status": "verified"');
|
|
} else {
|
|
expect(result.status, result.stderr).toBe(0);
|
|
const report = JSON.parse(result.stdout) as {
|
|
status: string;
|
|
tool_count: number;
|
|
};
|
|
expect(report.status).toBe("verified");
|
|
expect(report.tool_count).toBe(1);
|
|
expect(events.indexOf("transport-close-done\n")).toBeLessThan(
|
|
events.indexOf("lineage\n"),
|
|
);
|
|
expect(events.indexOf("commands-close-done\n")).toBeLessThan(
|
|
events.indexOf("lineage\n"),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|