feat(update): update Bun global installations (#1735)

* feat(update): update Bun global installations

`rea update` only recognized npm global installations and looked up
releases with `npm view`, so a `bun add --global rea-agents` install on a
machine without npm failed with `spawn npm ENOENT`.

Detect the running package inside Bun's global directory
(BUN_INSTALL_GLOBAL_DIR, $BUN_INSTALL/install/global,
$XDG_CACHE_HOME/.bun/install/global, ~/.bun/install/global), resolve the
latest release with `bun info`, install with
`bun add --global --ignore-scripts`, then verify the installed entry point
and plan integration maintenance exactly as for npm.

Bun commands run inside the owning global directory with
BUN_INSTALL_GLOBAL_DIR pinned, so the caller's current project is never
selected. A global directory without package.json is not treated as an
owned installation: Bun then installs into the nearest ancestor project.
npm installations keep their existing commands and messages.

* fix(update): gate Bun self-updates on supported CLI versions

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
xeropresence
2026-10-11 05:56:05 -04:00
committed by GitHub
parent e365f3715f
commit e4027e4afc
8 changed files with 492 additions and 51 deletions
+5 -2
View File
@@ -111,12 +111,15 @@ snapshots and scripting.
REA changes quickly, and new releases include frequent bug fixes. Keep your
installation up to date.
For an npm-installed CLI:
For a CLI installed with `npm install --global` or `bun add --global`:
```bash
rea update
```
Bun global updates require Bun 1.2.17 or later. With an older Bun release,
upgrade Bun manually before running `rea update`.
To refresh your agent registrations and skill, run the setup command printed
by the update.
@@ -276,7 +279,7 @@ model provider has its own data policy.
Update first; a recent release may already fix it.
For an npm-installed CLI:
For a CLI installed with `npm install --global` or `bun add --global`:
```bash
rea update
+16 -4
View File
@@ -400,9 +400,21 @@ rea setup --yes --all-detected --install-hopper --json
Setup pins package-runner MCP registrations to the exact installed REA version,
installs the matching skill and on-demand references in the same plan, and adds
`startup_timeout_sec = 30` for Codex and Grok Build. `rea update` installs the exact resolved
release into the npm prefix that owns the running package, then checks the new
executable's version before reporting success. It does not reopen onboarding.
Release lookup and installation both use npm's configured registry.
release into the npm prefix or Bun global directory that owns the running
package, then checks the new executable's version before reporting success. It
does not reopen onboarding. For npm installations, release lookup and
installation both use npm's configured registry. Bun global updates require
Bun 1.2.17 or later, which introduced `bun info`. The updater checks `bun --version`
first and stops with an instruction to upgrade Bun manually if the version is
older or cannot be parsed; it does not upgrade Bun itself.
For supported Bun global installations, `bun info` and
`bun add --global --ignore-scripts` use Bun's configured registry
and run inside the owning Bun global directory (`BUN_INSTALL_GLOBAL_DIR`, else
`$BUN_INSTALL/install/global`, else `$XDG_CACHE_HOME/.bun/install/global`,
else `~/.bun/install/global`), never in the caller's current project. A Bun
global directory without its `package.json` is not treated as an owned
installation, because Bun would install into the nearest ancestor project
instead.
Its maintenance plan selects only existing REA registrations and an already
installed REA skill. Run the returned scoped setup command to review and approve
those changes, then restart affected agents. The plan is returned in terminal,
@@ -824,7 +836,7 @@ Ghidra/JDK installation; neither is inferred from startup alone.
## Diagnose, update, and remove
`rea doctor --json` is strictly read-only. `rea update` updates only the npm installation that owns the running CLI. Source checkouts and package-runner copies must be updated through the mechanism that owns them; a fresh package-runner invocation can use `npx rea-agents@latest`. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
`rea doctor --json` is strictly read-only. `rea update` updates only the npm or Bun global installation that owns the running CLI. Source checkouts and package-runner copies must be updated through the mechanism that owns them; a fresh package-runner invocation can use `npx rea-agents@latest`. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
```bash
rea update
+120 -8
View File
@@ -1,3 +1,5 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { err, ok, type Result } from "../domain/result.js";
@@ -5,18 +7,22 @@ import {
runUpdate,
type ReleaseLookupFailure,
type UpdateHost,
type UpdateInstallation,
type UpdateOutput,
type NpmInstallation,
} from "./Update.js";
import {
bunGlobalDirectory,
detectBunInstallation,
detectNpmInstallation,
type BunInstallationHost,
type NpmInstallationHost,
} from "./UpdateRuntime.js";
import type { IntegrationMaintenance } from "./UpdateMaintenance.js";
class FakeUpdateHost implements UpdateHost {
release: Result<string, ReleaseLookupFailure> = ok("3.3.0");
installationResult: NpmInstallation | undefined = {
installationResult: UpdateInstallation | undefined = {
manager: "npm",
prefix: "/fixture",
packageRoot: "/fixture/lib/node_modules/rea-agents",
};
@@ -28,18 +34,18 @@ class FakeUpdateHost implements UpdateHost {
};
installedVersions: string[] = [];
installationInspected = false;
verifiedInstallation: NpmInstallation | undefined;
verifiedInstallation: UpdateInstallation | undefined;
maintenanceInspected = false;
output: UpdateOutput | undefined;
installation = (): Promise<NpmInstallation | undefined> => {
installation = (): Promise<UpdateInstallation | undefined> => {
this.installationInspected = true;
return Promise.resolve(this.installationResult);
};
latestVersion = (): Promise<Result<string, ReleaseLookupFailure>> =>
Promise.resolve(this.release);
installVersion = (
_installation: NpmInstallation,
_installation: UpdateInstallation,
version: string,
output: UpdateOutput,
): Promise<Result<void, string>> => {
@@ -48,7 +54,7 @@ class FakeUpdateHost implements UpdateHost {
return Promise.resolve(this.installResult);
};
installedVersion = (
installation: NpmInstallation,
installation: UpdateInstallation,
): Promise<Result<string, string>> => {
this.verifiedInstallation = installation;
return Promise.resolve(this.verified);
@@ -105,6 +111,32 @@ describe("REA update", () => {
expect(host.output).toBe("structured");
});
it("updates a Bun global installation with Bun and verifies the same package root", async () => {
const host = new FakeUpdateHost();
host.installationResult = {
manager: "bun",
globalDirectory: "/home/user/.bun/install/global",
packageRoot: "/home/user/.bun/install/global/node_modules/rea-agents",
};
expect(await runUpdate("3.2.1", host, "structured")).toMatchObject({
status: "updated",
installMethod: "bun",
installedVersion: "3.3.0",
command: [
"bun",
"add",
"--global",
"--ignore-scripts",
"rea-agents@3.3.0",
],
});
expect(host.verifiedInstallation).toEqual(host.installationResult);
expect(await runUpdate("3.3.0", host)).toMatchObject({
status: "current",
installMethod: "bun",
});
});
it("does not report success or plan maintenance when the installed version differs", async () => {
const host = new FakeUpdateHost();
host.verified = ok("3.4.0");
@@ -297,14 +329,18 @@ describe("npm installation ownership", () => {
const packageRoot = "/usr/local/lib/node_modules/rea-agents";
await expect(
detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")),
).resolves.toEqual({ prefix: "/usr/local", packageRoot });
).resolves.toEqual({ manager: "npm", prefix: "/usr/local", packageRoot });
});
it("retains the curl installer's custom Unix prefix", async () => {
const packageRoot = "/home/user/.local/lib/node_modules/rea-agents";
await expect(
detectNpmInstallation(packageRoot, host("/usr/local/lib/node_modules")),
).resolves.toEqual({ prefix: "/home/user/.local", packageRoot });
).resolves.toEqual({
manager: "npm",
prefix: "/home/user/.local",
packageRoot,
});
});
it.each([
@@ -330,3 +366,79 @@ describe("npm installation ownership", () => {
).resolves.toBeUndefined();
});
});
describe("Bun installation ownership", () => {
const globalDirectory = "/home/user/.bun/install/global";
const packageRoot = `${globalDirectory}/node_modules/rea-agents`;
// Fixture paths are POSIX; join() may produce Windows separators on Windows hosts.
const host = (
manifests: readonly string[] = [`${globalDirectory}/package.json`],
): BunInstallationHost => ({
canonicalPath: (path) => Promise.resolve(path.replaceAll("\\", "/")),
isFile: (path) =>
Promise.resolve(manifests.includes(path.replaceAll("\\", "/"))),
});
it("identifies the package inside Bun's global project", async () => {
await expect(
detectBunInstallation(packageRoot, globalDirectory, host()),
).resolves.toEqual({ manager: "bun", globalDirectory, packageRoot });
});
it("refuses a global directory without its manifest, which Bun would resolve to an ancestor project", async () => {
await expect(
detectBunInstallation(packageRoot, globalDirectory, host([])),
).resolves.toBeUndefined();
});
it.each([
"/home/user/project/node_modules/rea-agents",
"/usr/local/lib/node_modules/rea-agents",
"/home/user/.bun/install/cache/rea-agents@6.4.0@@@1",
])("rejects package root %s outside the global project", async (root) => {
await expect(
detectBunInstallation(root, globalDirectory, host()),
).resolves.toBeUndefined();
});
it("does not trust an unresolvable installation path", async () => {
await expect(
detectBunInstallation(packageRoot, globalDirectory, {
...host(),
canonicalPath: () => Promise.reject(new Error("ENOENT")),
}),
).resolves.toBeUndefined();
});
it("follows Bun's global directory precedence", () => {
const home = { HOME: "/home/user" };
expect(bunGlobalDirectory(home, "linux")).toBe(
join("/home/user", ".bun", "install", "global"),
);
expect(
bunGlobalDirectory({ ...home, XDG_CACHE_HOME: "/cache" }, "linux"),
).toBe(join("/cache", ".bun", "install", "global"));
expect(
bunGlobalDirectory(
{ ...home, XDG_CACHE_HOME: "/cache", BUN_INSTALL: "/opt/bun" },
"linux",
),
).toBe(join("/opt/bun", "install", "global"));
expect(
bunGlobalDirectory(
{
...home,
BUN_INSTALL: "/opt/bun",
BUN_INSTALL_GLOBAL_DIR: "/srv/bun-global",
},
"linux",
),
).toBe("/srv/bun-global");
expect(
bunGlobalDirectory(
{ USERPROFILE: "C:\\Users\\user", HOME: "/c/Users/user" },
"win32",
),
).toBe(join("C:\\Users\\user", ".bun", "install", "global"));
});
});
+53 -22
View File
@@ -7,34 +7,47 @@ export { isUpdateFailure } from "./UpdateFailure.js";
/** Global npm installation that owns the running package and its entry point. */
export interface NpmInstallation {
readonly manager: "npm";
readonly prefix: string;
readonly packageRoot: string;
}
/** Bun global installation that owns the running package and its entry point. */
export interface BunInstallation {
readonly manager: "bun";
/** Bun's global project directory, which owns `node_modules/rea-agents`. */
readonly globalDirectory: string;
readonly packageRoot: string;
}
/** Global package-manager installation that owns the running package. */
export type UpdateInstallation = NpmInstallation | BunInstallation;
/** Output policy for the package manager subprocess. */
export type UpdateOutput = "human" | "structured";
/** Why release lookup failed before a version could be compared. */
export type ReleaseLookupFailure =
| { readonly kind: "unavailable"; readonly detail: string }
| { readonly kind: "unsupported-package-manager"; readonly detail: string }
| { readonly kind: "invalid-metadata"; readonly detail: string };
/** Effects used to update and verify one identified REA installation. */
export interface UpdateHost {
installation(): Promise<NpmInstallation | undefined>;
installation(): Promise<UpdateInstallation | undefined>;
latestVersion(
installation: NpmInstallation | undefined,
installation: UpdateInstallation | undefined,
): Promise<Result<string, ReleaseLookupFailure>>;
installVersion(
installation: NpmInstallation,
installation: UpdateInstallation,
version: string,
output: UpdateOutput,
): Promise<Result<void, string>>;
installedVersion(
installation: NpmInstallation,
installation: UpdateInstallation,
): Promise<Result<string, string>>;
planMaintenance(
installation: NpmInstallation,
installation: UpdateInstallation,
): Promise<IntegrationMaintenance>;
}
@@ -44,14 +57,14 @@ export type UpdateResult =
readonly status: "current";
readonly currentVersion: string;
readonly latestVersion: string;
readonly installMethod: "npm" | "unknown";
readonly installMethod: UpdateInstallation["manager"] | "unknown";
}
| {
readonly status: "updated";
readonly previousVersion: string;
readonly latestVersion: string;
readonly installedVersion: string;
readonly installMethod: "npm";
readonly installMethod: UpdateInstallation["manager"];
readonly command: readonly string[];
readonly maintenance: IntegrationMaintenance;
readonly remediation: string;
@@ -69,19 +82,35 @@ export type UpdateResult =
readonly remediation: string;
};
/** Resolve the exact npm command for the identified prefix and release. */
/** Resolve the exact package-manager command for the identified installation and release. */
export const updateInstallCommand = (
installation: NpmInstallation,
installation: UpdateInstallation,
version: string,
): readonly string[] => [
"npm",
"install",
"--global",
"--ignore-scripts",
"--prefix",
installation.prefix,
`${PRODUCT_IDENTITY.packageName}@${version}`,
];
): readonly string[] => {
const release = `${PRODUCT_IDENTITY.packageName}@${version}`;
const manager = installation.manager;
switch (manager) {
case "npm":
return [
"npm",
"install",
"--global",
"--ignore-scripts",
"--prefix",
installation.prefix,
release,
];
case "bun":
// The host pins Bun's global directory and working directory for this command.
return ["bun", "add", "--global", "--ignore-scripts", release];
default: {
const exhaustive: never = manager;
throw new TypeError(
`Unhandled installation manager: ${String(exhaustive)}`,
);
}
}
};
const versionCheckRemediation = (
release: Result<string, ReleaseLookupFailure>,
@@ -92,6 +121,8 @@ const versionCheckRemediation = (
const failure = release.error;
const kind = failure.kind;
switch (kind) {
case "unsupported-package-manager":
return failure.detail;
case "unavailable":
return `REA could not resolve the latest release: ${failure.detail}. Retry rea update when registry access is available.`;
case "invalid-metadata":
@@ -128,7 +159,7 @@ export const runUpdate = async (
status: "current",
currentVersion,
latestVersion: targetVersion,
installMethod: installation === undefined ? "unknown" : "npm",
installMethod: installation?.manager ?? "unknown",
};
if (installation === undefined)
return {
@@ -150,7 +181,7 @@ export const runUpdate = async (
currentVersion,
latestVersion: targetVersion,
reason: "install",
remediation: `REA could not install ${targetVersion} in ${installation.prefix}: ${installed.error}. Repair this npm installation, then rerun rea update.`,
remediation: `REA could not install ${targetVersion} in ${installation.manager === "npm" ? installation.prefix : installation.globalDirectory}: ${installed.error}. Repair this ${installation.manager} installation, then rerun rea update.`,
};
const verified = await host.installedVersion(installation);
if (!verified.ok || verified.value !== targetVersion)
@@ -160,7 +191,7 @@ export const runUpdate = async (
latestVersion: targetVersion,
reason: "verification",
...(verified.ok ? { installedVersion: verified.value } : {}),
remediation: `npm completed, but REA could not verify ${targetVersion} in ${installation.packageRoot}: ${verified.ok ? `the executable reported ${verified.value}` : verified.error}. The installation may have changed; repair it before retrying.`,
remediation: `${installation.manager} completed, but REA could not verify ${targetVersion} in ${installation.packageRoot}: ${verified.ok ? `the executable reported ${verified.value}` : verified.error}. The installation may have changed; repair it before retrying.`,
};
const maintenance = await host.planMaintenance(installation);
return {
@@ -168,7 +199,7 @@ export const runUpdate = async (
previousVersion: currentVersion,
latestVersion: targetVersion,
installedVersion: verified.value,
installMethod: "npm",
installMethod: installation.manager,
command: updateInstallCommand(installation, targetVersion),
maintenance,
remediation:
@@ -10,9 +10,10 @@ import {
} from "./UpdateRuntime.js";
const installation = {
manager: "npm",
prefix: "/opt/rea",
packageRoot: "/opt/rea/lib/node_modules/rea-agents",
};
} as const;
describe("npm latest release metadata", () => {
it("accepts npm 11's scalar JSON response", () => {
+154 -12
View File
@@ -1,17 +1,20 @@
import { realpath } from "node:fs/promises";
import { realpath, stat } from "node:fs/promises";
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import spawn from "cross-spawn";
import { lt, valid } from "semver";
import { PRODUCT_IDENTITY } from "../identity.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
import {
updateInstallCommand,
type BunInstallation,
type NpmInstallation,
type ReleaseLookupFailure,
type UpdateHost,
type UpdateInstallation,
type UpdateOutput,
} from "./Update.js";
import { planIntegrationMaintenance } from "./UpdateMaintenance.js";
@@ -23,11 +26,18 @@ export interface NpmInstallationHost {
globalPrefix(): Promise<string>;
}
/** Filesystem queries used to identify a Bun global installation. */
export interface BunInstallationHost {
canonicalPath(path: string): Promise<string>;
isFile(path: string): Promise<boolean>;
}
/** Run an updater subprocess with complete diagnostics and isolated machine output. */
export const runUpdateCommand = (
command: readonly string[],
selectedEnvironment: Readonly<NodeJS.ProcessEnv>,
output: UpdateOutput = "structured",
workingDirectory: string | undefined = undefined,
): Promise<Result<string, string>> =>
new Promise((resolveResult) => {
const [executable, ...args] = command;
@@ -42,6 +52,7 @@ export const runUpdateCommand = (
stdio: ["ignore", "pipe", "pipe"],
env: environment,
windowsHide: true,
...(workingDirectory === undefined ? {} : { cwd: workingDirectory }),
});
let stdout = "";
let stderr = "";
@@ -102,7 +113,7 @@ export const detectNpmInstallation = async (
const prefix = await host.globalPrefix();
return prefix.length === 0
? undefined
: { prefix, packageRoot: canonicalPackageRoot };
: { manager: "npm", prefix, packageRoot: canonicalPackageRoot };
}
} catch (cause: unknown) {
// The curl installer uses a Unix prefix that may differ from npm's current default.
@@ -116,12 +127,80 @@ export const detectNpmInstallation = async (
basename(library) !== "lib"
)
return undefined;
return { prefix: dirname(library), packageRoot: canonicalPackageRoot };
return {
manager: "npm",
prefix: dirname(library),
packageRoot: canonicalPackageRoot,
};
};
const nonemptyEnvironment = (value: string | undefined): string | undefined =>
value === undefined || value.length === 0 ? undefined : value;
/**
* Bun's global project directory for the selected environment, in Bun's precedence:
* BUN_INSTALL_GLOBAL_DIR, then $BUN_INSTALL/install/global, then
* $XDG_CACHE_HOME/.bun/install/global, then ~/.bun/install/global.
*/
export const bunGlobalDirectory = (
environment: Readonly<NodeJS.ProcessEnv>,
platform: NodeJS.Platform,
): string => {
const explicit = nonemptyEnvironment(environment.BUN_INSTALL_GLOBAL_DIR);
if (explicit !== undefined) return explicit;
const installRoot = nonemptyEnvironment(environment.BUN_INSTALL);
if (installRoot !== undefined) return join(installRoot, "install", "global");
const cacheRoot =
nonemptyEnvironment(environment.XDG_CACHE_HOME) ??
homeDirectoryFromEnvironment(environment, platform);
return join(cacheRoot, ".bun", "install", "global");
};
/**
* Identify the running package as Bun's global installation.
* Bun walks up to an ancestor project when its global directory has no
* package.json, so the global manifest must exist before `bun add --global`
* can be trusted to update this package root.
*/
export const detectBunInstallation = async (
packageRoot: string,
globalDirectory: string,
host: BunInstallationHost,
): Promise<BunInstallation | undefined> => {
try {
const canonicalPackageRoot = await host.canonicalPath(packageRoot);
const globalPackageRoot = await host.canonicalPath(
join(globalDirectory, "node_modules", PRODUCT_IDENTITY.packageName),
);
if (
canonicalPackageRoot !== globalPackageRoot ||
!(await host.isFile(join(globalDirectory, "package.json")))
)
return undefined;
return {
manager: "bun",
globalDirectory,
packageRoot: canonicalPackageRoot,
};
} catch (cause: unknown) {
// best-effort cleanup: optional install-location probing; unknown means not Bun-managed.
void cause;
return undefined;
}
};
const INVALID_NPM_RELEASE_METADATA =
"Invalid npm release metadata: expected a version string or a single-element version array.";
/** bun info arguments for dist-tags.latest; Bun resolves its configured registry. */
export const BUN_LATEST_VERSION_COMMAND: readonly string[] = [
"bun",
"info",
PRODUCT_IDENTITY.packageName,
"dist-tags.latest",
"--json",
];
/** npm view arguments for dist-tags.latest, scoped to the owning prefix when known. */
export const npmLatestVersionCommand = (
installation: NpmInstallation | undefined,
@@ -162,7 +241,7 @@ const readNpmLatestVersion = (value: unknown): string | undefined => {
const nonemptyVersion = (value: string): string | undefined =>
value.length === 0 ? undefined : value;
/** Classify npm view output as a version or a lookup failure. */
/** Classify `npm view` or `bun info` output as a version or a lookup failure. */
export const npmReleaseLookup = (
response: Result<string, string>,
): Result<string, ReleaseLookupFailure> => {
@@ -173,13 +252,51 @@ export const npmReleaseLookup = (
: err({ kind: "invalid-metadata", detail: parsed.error });
};
/** Create npm, registry, verification, and read-only maintenance effects. */
const isFile = async (path: string): Promise<boolean> => {
try {
return (await stat(path)).isFile();
} catch (cause: unknown) {
// best-effort cleanup: a missing or unreadable manifest is not an owned Bun global project.
void cause;
return false;
}
};
/**
* Environment and working directory for package-manager subprocesses.
* Bun commands run inside the identified global project with that directory
* pinned, so the caller's current project and Bun's ancestor search are never
* selected. npm keeps the caller's working directory; its prefix is explicit.
*/
const packageManagerContext = (
installation: UpdateInstallation | undefined,
environment: Readonly<NodeJS.ProcessEnv>,
): {
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly workingDirectory: string | undefined;
} =>
installation?.manager === "bun"
? {
environment: {
...environment,
BUN_INSTALL_GLOBAL_DIR: installation.globalDirectory,
},
workingDirectory: installation.globalDirectory,
}
: { environment, workingDirectory: undefined };
/** Create package-manager, registry, verification, and read-only maintenance effects. */
export const systemUpdateHost = (
packageRoot = fileURLToPath(new URL("../..", import.meta.url)),
home: string | undefined = undefined,
environment: Readonly<NodeJS.ProcessEnv> = process.env,
): UpdateHost => ({
installation: () =>
installation: async () =>
(await detectBunInstallation(
packageRoot,
bunGlobalDirectory(environment, process.platform),
{ canonicalPath: realpath, isFile },
)) ??
detectNpmInstallation(packageRoot, {
canonicalPath: realpath,
globalRoot: () =>
@@ -187,18 +304,43 @@ export const systemUpdateHost = (
globalPrefix: () =>
requireCommandOutput(["npm", "prefix", "--global"], environment),
}),
latestVersion: async (installation) =>
npmReleaseLookup(
latestVersion: async (installation) => {
const context = packageManagerContext(installation, environment);
if (installation?.manager === "bun") {
const version = await runUpdateCommand(
["bun", "--version"],
context.environment,
"structured",
context.workingDirectory,
);
if (!version.ok)
return err({ kind: "unavailable", detail: version.error });
const bunVersion = valid(version.value);
// bun info was introduced in 1.2.17; older releases reserve the command.
if (bunVersion === null || lt(bunVersion, "1.2.17"))
return err({
kind: "unsupported-package-manager",
detail: `REA updates for Bun global installations require Bun 1.2.17 or later; bun --version returned ${JSON.stringify(version.value)}. Upgrade Bun manually, then rerun rea update.`,
});
}
return npmReleaseLookup(
await runUpdateCommand(
npmLatestVersionCommand(installation),
environment,
installation?.manager === "bun"
? BUN_LATEST_VERSION_COMMAND
: npmLatestVersionCommand(installation),
context.environment,
"structured",
context.workingDirectory,
),
),
);
},
installVersion: async (installation, version, output) => {
const context = packageManagerContext(installation, environment);
const result = await runUpdateCommand(
updateInstallCommand(installation, version),
environment,
context.environment,
output,
context.workingDirectory,
);
return result.ok ? ok(undefined) : result;
},
+2 -1
View File
@@ -42,9 +42,10 @@ it("does not treat ordinary error-looking data or nested Evidence as command fai
describe("CLI command outcome classification", () => {
it("returns nonzero for the UpdateResult failure variant", async () => {
const installation = {
manager: "npm",
prefix: "/fixture",
packageRoot: "/fixture/lib/node_modules/rea-agents",
};
} as const;
const updateHost: UpdateHost = {
installation: () => Promise.resolve(installation),
latestVersion: () =>
+140 -1
View File
@@ -1,8 +1,9 @@
import { mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { delimiter, dirname, join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { err, ok } from "../../src/domain/result.js";
import { runUpdate } from "../../src/application/Update.js";
import { PRODUCT_IDENTITY } from "../../src/identity.js";
import { supportedClients } from "../../src/application/SupportedClients.js";
import {
@@ -364,9 +365,147 @@ describe("update subprocess boundaries", () => {
await writeFile(script, 'process.stdout.write("3.3.0\\n")');
await expect(
systemUpdateHost(packageRoot, home).installedVersion({
manager: "npm",
prefix: home,
packageRoot,
}),
).resolves.toEqual(ok("3.3.0"));
});
});
describe("Bun global installation update", () => {
it.each([
["1.4.2", "updated"],
["1.2.17", "updated"],
["1.2.14", "failed"],
["1.2.17-canary.1", "failed"],
["unknown", "failed"],
])(
"handles Bun %s with outcome %s inside its global project",
async (bunVersion, status) => {
// A recording `bun` on PATH crosses the real subprocess boundary without registry access.
const root = await createTestTempDirectory("rea-update-bun-");
const globalDirectory = join(root, "bun", "install", "global");
const packageRoot = join(
globalDirectory,
"node_modules",
PRODUCT_IDENTITY.packageName,
);
const versionPath = join(root, "installed-version");
const logPath = join(root, "bun-invocations.jsonl");
await mkdir(join(packageRoot, "scripts"), { recursive: true });
await writeFile(
join(globalDirectory, "package.json"),
'{"dependencies":{"rea-agents":"3.2.1"}}\n',
);
await writeFile(versionPath, "3.2.1");
await writeFile(
join(packageRoot, "scripts", "rea.mjs"),
`import { readFileSync } from "node:fs";\nprocess.stdout.write(readFileSync(${JSON.stringify(versionPath)}, "utf8") + "\\n");\n`,
);
const binDirectory = join(root, "bin");
const recorder = join(binDirectory, "record-bun.mjs");
await mkdir(binDirectory);
await writeFile(
recorder,
[
'import { appendFileSync, writeFileSync } from "node:fs";',
"const args = process.argv.slice(2);",
`appendFileSync(${JSON.stringify(logPath)}, JSON.stringify({ args, cwd: process.cwd(), globalDirectory: process.env.BUN_INSTALL_GLOBAL_DIR }) + "\\n");`,
`if (JSON.stringify(args) === '["--version"]') console.log(${JSON.stringify(bunVersion)});`,
`else if (JSON.stringify(args) === ${JSON.stringify(JSON.stringify(["info", PRODUCT_IDENTITY.packageName, "dist-tags.latest", "--json"]))}) process.stdout.write('"3.3.0"\\n');`,
`else if (JSON.stringify(args) === ${JSON.stringify(JSON.stringify(["add", "--global", "--ignore-scripts", `${PRODUCT_IDENTITY.packageName}@3.3.0`]))}) writeFileSync(${JSON.stringify(versionPath)}, "3.3.0");`,
"else process.exitCode = 2;",
"",
].join("\n"),
);
if (process.platform === "win32") {
await writeFile(
join(binDirectory, "bun.cmd"),
`@"${process.execPath}" "${recorder}" %*\r\n`,
);
} else {
await writeFile(
join(binDirectory, "bun"),
`#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(recorder)} "$@"\n`,
{ mode: 0o755 },
);
}
const pathKey =
Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ??
"PATH";
const environment: NodeJS.ProcessEnv = {
...process.env,
[pathKey]: `${binDirectory}${delimiter}${process.env[pathKey] ?? ""}`,
BUN_INSTALL: join(root, "bun"),
};
delete environment.BUN_INSTALL_GLOBAL_DIR;
const result = await runUpdate(
"3.2.1",
systemUpdateHost(packageRoot, home, environment),
"structured",
);
const invocations = (await readFile(logPath, "utf8"))
.trim()
.split("\n")
.map((line): unknown => JSON.parse(line));
const versionInvocation = {
args: ["--version"],
cwd: globalDirectory,
globalDirectory,
};
if (status === "failed") {
expect(result).toEqual({
status: "failed",
currentVersion: "3.2.1",
latestVersion: null,
reason: "version-check",
remediation: `REA updates for Bun global installations require Bun 1.2.17 or later; bun --version returned ${JSON.stringify(bunVersion)}. Upgrade Bun manually, then rerun rea update.`,
});
expect(invocations).toEqual([versionInvocation]);
expect(await readFile(versionPath, "utf8")).toBe("3.2.1");
return;
}
expect(result).toMatchObject({
status: "updated",
installMethod: "bun",
installedVersion: "3.3.0",
command: [
"bun",
"add",
"--global",
"--ignore-scripts",
`${PRODUCT_IDENTITY.packageName}@3.3.0`,
],
});
// `bun info` needs a project in its working directory, and the caller's
// directory must never be selected, so all commands run in the owner.
expect(invocations).toEqual([
versionInvocation,
{
args: [
"info",
PRODUCT_IDENTITY.packageName,
"dist-tags.latest",
"--json",
],
cwd: globalDirectory,
globalDirectory,
},
{
args: [
"add",
"--global",
"--ignore-scripts",
`${PRODUCT_IDENTITY.packageName}@3.3.0`,
],
cwd: globalDirectory,
globalDirectory,
},
]);
},
);
});