Merge pull request #89 from morluto/feat/cli-upgrade
feat(cli): add self-upgrade command
This commit is contained in:
5 files changed
+358
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user