Merge pull request #89 from morluto/feat/cli-upgrade

feat(cli): add self-upgrade command
This commit is contained in:
morluto authored and GitHub committed 2026-07-13 13:36:47 +08:00
commit c004220a26
5 files changed
+358

No files matched your search

+12
View File
@@ -120,6 +120,17 @@ rea doctor
rea analyze /Applications/Notes.app
```
Update that global installation in place:
```bash
rea upgrade
```
REA checks npm for the latest release and verifies that the running package is
the global installation it will replace. Source, local, and `npx` copies report
the manual `npm install --global rea-agents@latest` command instead of updating
an unrelated global package.
Choose either the no-install commands or the global installation. You do not need both.
### Requirements
@@ -343,6 +354,7 @@ Or install the `rea` command globally:
```bash
npm install --global rea-agents
rea --help
rea upgrade
rea mcp
```
+1
View File
@@ -119,6 +119,7 @@ try {
const expectedDoctorHealth = supportedSetupHost;
if (
!help.includes("setup") ||
!help.includes("upgrade") ||
!help.includes("inventory-artifact") ||
!help.includes("extract-artifact") ||
!help.includes("import-reference-source") ||
+197
View File
@@ -0,0 +1,197 @@
import { execFile, spawn } from "node:child_process";
import { realpath } from "node:fs/promises";
import { basename, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { z } from "zod";
import { PRODUCT_IDENTITY } from "../identity.js";
const execFileAsync = promisify(execFile);
const registryResponseSchema = z.object({ version: z.string().min(1) });
const UPGRADE_COMMAND = "npm install --global rea-agents@latest";
/** Location of the global npm installation that owns the running CLI. */
export interface NpmInstallation {
readonly prefix: string;
}
/** npm filesystem queries used to identify the installation being updated. */
export interface NpmInstallationHost {
canonicalPath(path: string): Promise<string>;
globalRoot(): Promise<string>;
globalPrefix(): Promise<string>;
}
/** Output channel policy for the package manager subprocess. */
export type UpgradeOutput = "human" | "structured";
/** Effects required to inspect and update the installed CLI. */
export interface UpgradeHost {
latestVersion(): Promise<string | undefined>;
installation(): Promise<NpmInstallation | undefined>;
installLatest(
installation: NpmInstallation,
output: UpgradeOutput,
): Promise<boolean>;
}
/** Caller-visible outcome of checking and updating the REA CLI. */
export type UpgradeResult =
| {
readonly status: "current";
readonly currentVersion: string;
readonly latestVersion: string;
readonly installMethod: "npm";
}
| {
readonly status: "upgraded";
readonly previousVersion: string;
readonly latestVersion: string | null;
readonly versionCheck: "available" | "unavailable";
readonly installMethod: "npm";
readonly command: typeof UPGRADE_COMMAND;
}
| {
readonly status: "failed";
readonly currentVersion: string;
readonly latestVersion: string | null;
readonly reason: "unknown-install-method" | "install";
readonly remediation: string;
};
/** Check the npm registry and update the same global REA installation. */
export const runUpgrade = async (
currentVersion: string,
host: UpgradeHost = systemUpgradeHost(),
output: UpgradeOutput = "human",
): Promise<UpgradeResult> => {
const latestVersion = await host.latestVersion();
if (latestVersion === currentVersion)
return {
status: "current",
currentVersion,
latestVersion,
installMethod: "npm",
};
const installation = await host.installation();
if (installation === undefined)
return {
status: "failed",
currentVersion,
latestVersion: latestVersion ?? null,
reason: "unknown-install-method",
remediation: `Update manually with: ${UPGRADE_COMMAND}`,
};
if (!(await host.installLatest(installation, output)))
return {
status: "failed",
currentVersion,
latestVersion: latestVersion ?? null,
reason: "install",
remediation: `${UPGRADE_COMMAND} failed.`,
};
return {
status: "upgraded",
previousVersion: currentVersion,
latestVersion: latestVersion ?? null,
versionCheck: latestVersion === undefined ? "unavailable" : "available",
installMethod: "npm",
command: UPGRADE_COMMAND,
};
};
/** Create the npm registry and subprocess effects for a production upgrade. */
export const systemUpgradeHost = (): UpgradeHost => ({
latestVersion: async () => {
try {
const response = await fetch(
`https://registry.npmjs.org/${PRODUCT_IDENTITY.packageName}/latest`,
{ signal: AbortSignal.timeout(10_000) },
);
if (!response.ok) return undefined;
const parsed = registryResponseSchema.safeParse(await response.json());
return parsed.success ? parsed.data.version : undefined;
} catch {
return undefined;
}
},
installation: () =>
detectNpmInstallation(
fileURLToPath(new URL("../..", import.meta.url)),
systemNpmInstallationHost,
),
installLatest: ({ prefix }, output) =>
runCommand(
"npm",
[
"install",
"--global",
"--prefix",
prefix,
`${PRODUCT_IDENTITY.packageName}@latest`,
],
output,
),
});
const systemNpmInstallationHost: NpmInstallationHost = {
canonicalPath: realpath,
globalRoot: async () =>
(await execFileAsync("npm", ["root", "--global"])).stdout.trim(),
globalPrefix: async () =>
(await execFileAsync("npm", ["prefix", "--global"])).stdout.trim(),
};
/** Identify the npm prefix only when it owns the running package. */
export const detectNpmInstallation = async (
packageRoot: string,
host: NpmInstallationHost,
): Promise<NpmInstallation | undefined> => {
try {
const canonicalPackageRoot = await host.canonicalPath(packageRoot);
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 };
}
return inferUnixGlobalPrefix(canonicalPackageRoot);
} catch {
return inferUnixGlobalPrefix(packageRoot);
}
};
const inferUnixGlobalPrefix = (
packageRoot: string,
): NpmInstallation | undefined => {
const nodeModules = dirname(packageRoot);
const library = dirname(nodeModules);
if (
basename(packageRoot) !== PRODUCT_IDENTITY.packageName ||
basename(nodeModules) !== "node_modules" ||
basename(library) !== "lib"
)
return undefined;
return { prefix: dirname(library) };
};
const runCommand = (
command: string,
arguments_: readonly string[],
output: UpgradeOutput,
): Promise<boolean> =>
new Promise((resolveResult) => {
const child = spawn(command, [...arguments_], {
stdio:
output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
});
child.once("error", () => resolveResult(false));
child.once("exit", (code) => resolveResult(code === 0));
});
+12
View File
@@ -9,6 +9,7 @@ import {
} from "./application/DirectAnalysis.js";
import { runSetup } from "./application/Setup.js";
import { runUninstall } from "./application/Uninstall.js";
import { runUpgrade, systemUpgradeHost } from "./application/Upgrade.js";
import { PRODUCT_IDENTITY } from "./identity.js";
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
import { logCliCommand } from "./cliLogging.js";
@@ -101,6 +102,17 @@ const registerCoreCommands = (
run: ({ options }) =>
logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
});
cli.command("upgrade", {
description: "Upgrade a global npm installation to the latest REA release",
run: ({ formatExplicit }) =>
logCliCommand(logger, "upgrade", () =>
runUpgrade(
process.env.REA_PACKAGE_VERSION ?? "0.0.0-development",
systemUpgradeHost(),
formatExplicit ? "structured" : "human",
),
),
});
cli.command("analyze", {
description: "Get an overview of an app",
args: z.object({
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import {
detectNpmInstallation,
runUpgrade,
type UpgradeHost,
type UpgradeOutput,
type NpmInstallationHost,
} from "../src/application/Upgrade.js";
class FakeUpgradeHost implements UpgradeHost {
latest: string | undefined = "0.6.0";
installationResult: { readonly prefix: string } | undefined = {
prefix: "/fixture",
};
installSucceeds = true;
installs = 0;
output: UpgradeOutput | undefined;
latestVersion = (): Promise<string | undefined> =>
Promise.resolve(this.latest);
installation = (): Promise<{ readonly prefix: string } | undefined> =>
Promise.resolve(this.installationResult);
installLatest = (
_installation: { readonly prefix: string },
output: UpgradeOutput,
): Promise<boolean> => {
this.installs += 1;
this.output = output;
return Promise.resolve(this.installSucceeds);
};
}
describe("CLI upgrade", () => {
it("reports an already-current release without mutation", async () => {
const host = new FakeUpgradeHost();
expect(await runUpgrade("0.6.0", host)).toEqual({
status: "current",
currentVersion: "0.6.0",
latestVersion: "0.6.0",
installMethod: "npm",
});
expect(host.installs).toBe(0);
});
it("upgrades an outdated global npm installation", async () => {
const host = new FakeUpgradeHost();
expect(await runUpgrade("0.5.0", host)).toEqual({
status: "upgraded",
previousVersion: "0.5.0",
latestVersion: "0.6.0",
versionCheck: "available",
installMethod: "npm",
command: "npm install --global rea-agents@latest",
});
expect(host.installs).toBe(1);
});
it("continues with npm latest when the registry check is unavailable", async () => {
const host = new FakeUpgradeHost();
host.latest = undefined;
expect(await runUpgrade("0.5.0", host)).toMatchObject({
status: "upgraded",
latestVersion: null,
versionCheck: "unavailable",
});
expect(host.installs).toBe(1);
});
it("preserves stdout for an explicitly structured CLI response", async () => {
const host = new FakeUpgradeHost();
await runUpgrade("0.5.0", host, "structured");
expect(host.output).toBe("structured");
});
it("refuses to mutate an unrelated installation", async () => {
const host = new FakeUpgradeHost();
host.installationResult = undefined;
expect(await runUpgrade("0.5.0", host)).toMatchObject({
status: "failed",
reason: "unknown-install-method",
remediation:
"Update manually with: npm install --global rea-agents@latest",
});
expect(host.installs).toBe(0);
});
it("reports npm installation failure", async () => {
const host = new FakeUpgradeHost();
host.installSucceeds = false;
expect(await runUpgrade("0.5.0", host)).toMatchObject({
status: "failed",
reason: "install",
});
expect(host.installs).toBe(1);
});
});
describe("npm installation detection", () => {
const host = (root: string, prefix = "/usr/local"): NpmInstallationHost => ({
canonicalPath: (path) => Promise.resolve(path),
globalRoot: () => Promise.resolve(root),
globalPrefix: () => Promise.resolve(prefix),
});
it("returns the prefix for the global package that owns the running CLI", async () => {
await expect(
detectNpmInstallation(
"/usr/local/lib/node_modules/rea-agents",
host("/usr/local/lib/node_modules"),
),
).resolves.toEqual({ prefix: "/usr/local" });
});
it("infers the prefix used by the curl installer's custom npm layout", async () => {
await expect(
detectNpmInstallation(
"/home/user/.local/lib/node_modules/rea-agents",
host("/usr/local/lib/node_modules"),
),
).resolves.toEqual({ prefix: "/home/user/.local" });
});
it("rejects a source checkout when a different global copy exists", async () => {
await expect(
detectNpmInstallation("/work/rea", host("/usr/local/lib/node_modules")),
).resolves.toBeUndefined();
});
});