mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
6256b68666
* feat(setup)!: expand agent integrations and simplify onboarding Add native configuration formats for OpenCode, Antigravity, GitHub Copilot CLI, VS Code, and Devin. Preserve JSONC comments and unrelated settings while making setup and uninstall ownership-aware and idempotent. Select agent targets before planning, separate discovery from intent, and keep agent-only setup independent of optional provider prerequisites. Cover configuration boundaries and interactive behavior in packaged checks. BREAKING CHANGE: unscoped setup refreshes only existing REA-owned registrations. Use --all-detected to configure every detected client. Setup JSON contains a compact doctor summary; use rea doctor for full diagnostics. Cancellation and successful dry runs now exit successfully. * feat(cli)!: replace upgrade with verified updates Resolve an exact release through npm's configured registry, update only the identified installation, and verify its new executable before reporting success. Return a read-only maintenance plan for existing enabled REA integrations. BREAKING CHANGE: replace rea upgrade with rea update; no alias is retained. Successful update JSON uses status "updated", reports the verified installed version, and returns an unapplied maintenance plan instead of opening setup.
169 lines
5.4 KiB
TypeScript
169 lines
5.4 KiB
TypeScript
import { realpath } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import { basename, dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import spawn from "cross-spawn";
|
|
import { z } from "zod";
|
|
|
|
import { PRODUCT_IDENTITY } from "../identity.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import {
|
|
updateInstallCommand,
|
|
type NpmInstallation,
|
|
type UpdateHost,
|
|
type UpdateOutput,
|
|
} from "./Update.js";
|
|
import { planIntegrationMaintenance } from "./UpdateMaintenance.js";
|
|
|
|
/** npm filesystem queries used to identify the installation being updated. */
|
|
export interface NpmInstallationHost {
|
|
canonicalPath(path: string): Promise<string>;
|
|
globalRoot(): Promise<string>;
|
|
globalPrefix(): Promise<string>;
|
|
}
|
|
|
|
/** Run an updater subprocess with complete diagnostics and isolated machine output. */
|
|
export const runUpdateCommand = (
|
|
command: readonly string[],
|
|
output: UpdateOutput = "structured",
|
|
): Promise<Result<string, string>> =>
|
|
new Promise((resolveResult) => {
|
|
const [executable, ...args] = command;
|
|
if (executable === undefined) {
|
|
resolveResult(err("No executable supplied."));
|
|
return;
|
|
}
|
|
const environment = { ...process.env };
|
|
// A fresh installed entry point must not inherit npx's invocation identity.
|
|
delete environment.npm_command;
|
|
const child = spawn(executable, args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: environment,
|
|
windowsHide: true,
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout?.setEncoding("utf8");
|
|
child.stderr?.setEncoding("utf8");
|
|
child.stdout?.on("data", (chunk: string) => {
|
|
stdout += chunk;
|
|
if (output === "human") process.stderr.write(chunk);
|
|
});
|
|
child.stderr?.on("data", (chunk: string) => {
|
|
stderr += chunk;
|
|
if (output === "human") process.stderr.write(chunk);
|
|
});
|
|
child.once("error", (cause) => resolveResult(err(cause.message)));
|
|
child.once("close", (code, signal) =>
|
|
resolveResult(
|
|
code === 0
|
|
? ok(stdout.trim())
|
|
: err(
|
|
stderr.trim() ||
|
|
stdout.trim() ||
|
|
(signal === null
|
|
? `Process exited with code ${String(code)}.`
|
|
: `Process terminated by ${signal}.`),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
const requireCommandOutput = async (
|
|
command: readonly string[],
|
|
): Promise<string> => {
|
|
const result = await runUpdateCommand(command);
|
|
if (!result.ok) throw new Error(result.error);
|
|
return result.value;
|
|
};
|
|
|
|
/** Resolve the global prefix only when it owns the running package. */
|
|
export const detectNpmInstallation = async (
|
|
packageRoot: string,
|
|
host: NpmInstallationHost,
|
|
): Promise<NpmInstallation | undefined> => {
|
|
let canonicalPackageRoot: string;
|
|
try {
|
|
canonicalPackageRoot = await host.canonicalPath(packageRoot);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const npmRoot = await host.globalRoot();
|
|
const globalPackageRoot = await host.canonicalPath(
|
|
resolve(npmRoot, PRODUCT_IDENTITY.packageName),
|
|
);
|
|
if (canonicalPackageRoot === globalPackageRoot) {
|
|
const prefix = await host.globalPrefix();
|
|
return prefix.length === 0
|
|
? undefined
|
|
: { prefix, packageRoot: canonicalPackageRoot };
|
|
}
|
|
} catch {
|
|
// The curl installer uses a Unix prefix that may differ from npm's current default.
|
|
}
|
|
const nodeModules = dirname(canonicalPackageRoot);
|
|
const library = dirname(nodeModules);
|
|
if (
|
|
basename(canonicalPackageRoot) !== PRODUCT_IDENTITY.packageName ||
|
|
basename(nodeModules) !== "node_modules" ||
|
|
basename(library) !== "lib"
|
|
)
|
|
return undefined;
|
|
return { prefix: dirname(library), packageRoot: canonicalPackageRoot };
|
|
};
|
|
|
|
/** Create npm, registry, verification, and read-only maintenance effects. */
|
|
export const systemUpdateHost = (
|
|
packageRoot = fileURLToPath(new URL("../..", import.meta.url)),
|
|
home = homedir(),
|
|
): UpdateHost => ({
|
|
installation: () =>
|
|
detectNpmInstallation(packageRoot, {
|
|
canonicalPath: realpath,
|
|
globalRoot: () => requireCommandOutput(["npm", "root", "--global"]),
|
|
globalPrefix: () => requireCommandOutput(["npm", "prefix", "--global"]),
|
|
}),
|
|
latestVersion: async (installation) => {
|
|
const response = await runUpdateCommand([
|
|
"npm",
|
|
"view",
|
|
"--global",
|
|
...(installation === undefined ? [] : ["--prefix", installation.prefix]),
|
|
PRODUCT_IDENTITY.packageName,
|
|
"dist-tags.latest",
|
|
"--json",
|
|
"--fetch-retries=0",
|
|
"--fetch-timeout=10000",
|
|
]);
|
|
if (!response.ok) return response;
|
|
try {
|
|
const parsed = z.string().min(1).safeParse(JSON.parse(response.value));
|
|
return parsed.success
|
|
? ok(parsed.data)
|
|
: err(`Invalid npm release metadata: ${parsed.error.message}`);
|
|
} catch (cause: unknown) {
|
|
return err(cause instanceof Error ? cause.message : String(cause));
|
|
}
|
|
},
|
|
installVersion: async (installation, version, output) => {
|
|
const result = await runUpdateCommand(
|
|
updateInstallCommand(installation, version),
|
|
output,
|
|
);
|
|
return result.ok ? ok(undefined) : result;
|
|
},
|
|
installedVersion: (installation) =>
|
|
runUpdateCommand([
|
|
process.execPath,
|
|
join(installation.packageRoot, "scripts", "rea.mjs"),
|
|
"--version",
|
|
]),
|
|
planMaintenance: (installation) =>
|
|
planIntegrationMaintenance(
|
|
home,
|
|
join(installation.packageRoot, "scripts", "rea.mjs"),
|
|
runUpdateCommand,
|
|
),
|
|
});
|