mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user