diff --git a/README.md b/README.md index 639dfb6eaf..48ef8efcaa 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/scripts/verify-package.mjs b/scripts/verify-package.mjs index 3676f674ba..78d718082b 100644 --- a/scripts/verify-package.mjs +++ b/scripts/verify-package.mjs @@ -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") || diff --git a/src/application/Upgrade.ts b/src/application/Upgrade.ts new file mode 100644 index 0000000000..fdd35f908a --- /dev/null +++ b/src/application/Upgrade.ts @@ -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; + globalRoot(): Promise; + globalPrefix(): Promise; +} + +/** 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; + installation(): Promise; + installLatest( + installation: NpmInstallation, + output: UpgradeOutput, + ): Promise; +} + +/** 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 => { + 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 => { + 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 => + 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)); + }); diff --git a/src/cli.ts b/src/cli.ts index a16b049d7e..49c8d84b0a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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({ diff --git a/tests/upgrade.test.ts b/tests/upgrade.test.ts new file mode 100644 index 0000000000..203f5ea9fe --- /dev/null +++ b/tests/upgrade.test.ts @@ -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 => + Promise.resolve(this.latest); + installation = (): Promise<{ readonly prefix: string } | undefined> => + Promise.resolve(this.installationResult); + installLatest = ( + _installation: { readonly prefix: string }, + output: UpgradeOutput, + ): Promise => { + 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(); + }); +});