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 { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.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"; import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js"; /** npm filesystem queries used to identify the installation being updated. */ export interface NpmInstallationHost { canonicalPath(path: string): Promise; globalRoot(): Promise; globalPrefix(): Promise; } /** Filesystem queries used to identify a Bun global installation. */ export interface BunInstallationHost { canonicalPath(path: string): Promise; isFile(path: string): Promise; } /** Run an updater subprocess with complete diagnostics and isolated machine output. */ export const runUpdateCommand = ( command: readonly string[], selectedEnvironment: Readonly, output: UpdateOutput = "structured", workingDirectory: string | undefined = undefined, ): Promise> => new Promise((resolveResult) => { const [executable, ...args] = command; if (executable === undefined) { resolveResult(err("No executable supplied.")); return; } // A fresh installed entry point must not inherit a package runner's invocation identity. const environment = withoutPackageRunnerIdentity(selectedEnvironment); const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], env: environment, windowsHide: true, ...(workingDirectory === undefined ? {} : { cwd: workingDirectory }), }); let stdout = ""; let stderr = ""; child.stdout?.setEncoding("utf8"); child.stderr?.setEncoding("utf8"); child.stdout?.on("data", (chunk: string) => { stdout += chunk; if (output === "human") process.stderr.write(chunk); }); child.stderr?.on("data", (chunk: string) => { stderr += chunk; if (output === "human") process.stderr.write(chunk); }); child.once("error", (cause) => resolveResult(err(cause.message))); child.once("close", (code, signal) => resolveResult( code === 0 ? ok(stdout.trim()) : err( stderr.trim() || stdout.trim() || (signal === null ? `Process exited with code ${String(code)}.` : `Process terminated by ${signal}.`), ), ), ); }); const requireCommandOutput = async ( command: readonly string[], environment: Readonly, ): Promise => { const result = await runUpdateCommand(command, environment); if (!result.ok) throw new Error(result.error); return result.value; }; /** Resolve the global prefix only when it owns the running package. */ export const detectNpmInstallation = async ( packageRoot: string, host: NpmInstallationHost, ): Promise => { let canonicalPackageRoot: string; try { canonicalPackageRoot = await host.canonicalPath(packageRoot); } catch (cause: unknown) { // best-effort cleanup: optional install-location probing; unknown means not npm-managed. void cause; return undefined; } try { 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 : { manager: "npm", prefix, packageRoot: canonicalPackageRoot }; } } catch (cause: unknown) { // The curl installer uses a Unix prefix that may differ from npm's current default. void cause; } const nodeModules = dirname(canonicalPackageRoot); const library = dirname(nodeModules); if ( basename(canonicalPackageRoot) !== PRODUCT_IDENTITY.packageName || basename(nodeModules) !== "node_modules" || basename(library) !== "lib" ) return undefined; 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. * Resolve against the caller's cwd before reusing the path as a subprocess cwd. */ export const bunGlobalDirectory = ( environment: Readonly, platform: NodeJS.Platform, ): string => { const explicit = nonemptyEnvironment(environment.BUN_INSTALL_GLOBAL_DIR); if (explicit !== undefined) return resolve(explicit); const installRoot = nonemptyEnvironment(environment.BUN_INSTALL); if (installRoot !== undefined) return resolve(installRoot, "install", "global"); const cacheRoot = nonemptyEnvironment(environment.XDG_CACHE_HOME) ?? homeDirectoryFromEnvironment(environment, platform); return resolve(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 => { 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, ): readonly string[] => [ "npm", "view", "--global", ...(installation === undefined ? [] : ["--prefix", installation.prefix]), PRODUCT_IDENTITY.packageName, "dist-tags.latest", "--json", "--fetch-retries=0", "--fetch-timeout=10000", ]; /** * Read one latest version from `npm view --json`. * npm 11 emits a JSON string. npm 12 emits that string as a one-element array. */ export const parseNpmLatestVersion = ( metadata: string, ): Result => { const decoded = safeParseJson(metadata); if (!decoded.ok) return err(decoded.error); const version = readNpmLatestVersion(decoded.value); return version === undefined ? err(INVALID_NPM_RELEASE_METADATA) : ok(version); }; const readNpmLatestVersion = (value: unknown): string | undefined => { if (typeof value === "string") return nonemptyVersion(value); if (!Array.isArray(value) || value.length !== 1) return undefined; const only = value[0]; return typeof only === "string" ? nonemptyVersion(only) : undefined; }; const nonemptyVersion = (value: string): string | undefined => value.length === 0 ? undefined : value; /** Classify `npm view` or `bun info` output as a version or a lookup failure. */ export const npmReleaseLookup = ( response: Result, ): Result => { if (!response.ok) return err({ kind: "unavailable", detail: response.error }); const parsed = parseNpmLatestVersion(response.value); return parsed.ok ? ok(parsed.value) : err({ kind: "invalid-metadata", detail: parsed.error }); }; const isFile = async (path: string): Promise => { 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, ): { readonly environment: Readonly; 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 = process.env, ): UpdateHost => { const resources = new ArtifactResourceScope(); return { installation: async () => (await detectBunInstallation( packageRoot, bunGlobalDirectory(environment, process.platform), { canonicalPath: realpath, isFile }, )) ?? detectNpmInstallation(packageRoot, { canonicalPath: realpath, globalRoot: () => requireCommandOutput(["npm", "root", "--global"], environment), globalPrefix: () => requireCommandOutput(["npm", "prefix", "--global"], environment), }), 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( 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), context.environment, output, context.workingDirectory, ); return result.ok ? ok(undefined) : result; }, installedVersion: (installation) => runUpdateCommand( [ process.execPath, join(installation.packageRoot, "scripts", "rea.mjs"), "--version", ], environment, ), planMaintenance: (installation) => planIntegrationMaintenance( home ?? homeDirectoryFromEnvironment(environment, process.platform), join(installation.packageRoot, "scripts", "rea.mjs"), environment, (command) => runUpdateCommand(command, environment), resources, ), close: async () => { try { await resources.close(); return undefined; } catch (cause: unknown) { return `Update resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`; } }, }; };